Compare commits

..
Author SHA1 Message Date
Aiden Cline 5120f6c1c4 feat(codemode): expose host classes and functions through extensions 2026-09-14 02:17:38 -05:00
Aiden Cline cbe8823671 feat(core): rework MCP client for SDK v2 and the 2026-07-28 revision (#48937) 2026-09-14 01:42:53 -05:00
Mohammad AandAiden Cline 6e43875123 fix(nix): enable Wayland clipboard images (#48928)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-09-14 01:00:04 -05:00
ReStranger ce380fba29 fix(nix): install opencode and keep opencode2 alias (#48662) 2026-09-14 00:59:22 -05:00
Aiden Cline 3045156fa6 fix(codemode): strip __proto__ keys at host object boundaries (#48926) 2026-09-14 00:49:42 -05:00
Luke Parker 0a3dc16961 fix(app): shorten context usage label (#48927) 2026-09-14 05:36:08 +00:00
Aiden Cline 1d4d9b5fa5 fix(ai): preserve OpenAI Chat image URLs (#48862) 2026-09-13 23:43:39 -05:00
Aiden Cline 752d980770 fix(ai): map Vertex service tier to header (#48886) 2026-09-13 23:43:16 -05:00
Aiden Cline 07225172df fix(ai): serialize undefined historical tool input (#48863) 2026-09-13 23:43:07 -05:00
Aiden Cline 223b0271e6 test: align platform and endpoint expectations (#48916) 2026-09-14 04:25:34 +00:00
Luke Parker 93a37958a7 fix(session-ui): remove sticky patch header gap (#48909) 2026-09-14 13:41:07 +10:00
Dax Raad 40f4778296 refactor(protocol): refine audited endpoints 2026-09-13 23:10:37 -04:00
Dax e9bb6d490b refactor(tui): remove terminal pane setting 2026-09-13 23:06:13 -04:00
Aiden Cline 1e697962bd fix: restore required typecheck status (#48913) 2026-09-13 22:03:22 -05:00
Aiden Cline 4418df5d62 test(app): align worker status URL (#48911) 2026-09-13 21:55:47 -05:00
Aiden Cline 052be04466 test: align coverage with v2 refactors (#48900) 2026-09-13 21:44:49 -05:00
Aiden Cline cd3a64b225 test: align status and shell expectations (#48906) 2026-09-13 21:32:42 -05:00
Dax Raad 48c9a0a8de feat(release): add interactive review 2026-09-13 22:22:27 -04:00
Aiden Cline 48875190ef feat(codemode): fail runaway recursion with a RangeError at 10000 nested calls (#48891) 2026-09-13 21:04:36 -05:00
opencode-agent[bot]andHona 476432de1e fix(core): restore Windows Git fast path (#48879)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-14 12:03:03 +10:00
Luke Parker a71bb4d38c fix(app): match notice updates to used label styling (#48895) 2026-09-14 12:02:16 +10:00
Aiden Cline 5d3019e5a1 refactor(codemode): materialize interpreter failures once and locate them at the call boundary (#48770) 2026-09-13 20:19:31 -05:00
151 changed files with 4269 additions and 3526 deletions
+1
View File
@@ -9,6 +9,7 @@ on:
jobs:
check:
name: typecheck
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout repository
+1
View File
@@ -32,6 +32,7 @@ target
# Local dev files
opencode-dev
UPCOMING_CHANGELOG.md
RELEASE_REVIEW.md
logs/
*.bun-build
tsconfig.tsbuildinfo
+18 -18
View File
@@ -120,17 +120,17 @@ Review endpoints in document order. For each endpoint, select one disposition an
| [ ] 033 | `POST` | `/api/integration/{integrationID}/connect/command` | `integration.command.connect` | | |
| [ ] 034 | `GET` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.status` | | |
| [ ] 035 | `DELETE` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.cancel` | | |
| [ ] 036 | `GET` | `/api/mcp` | `mcp.list` | | |
| [ ] 037 | `PUT` | `/api/mcp/{server}` | `mcp.add` | | |
| [ ] 038 | `DELETE` | `/api/mcp/{server}` | `mcp.remove` | | |
| [ ] 039 | `POST` | `/api/mcp/{server}/connect` | `mcp.connect` | | |
| [ ] 040 | `POST` | `/api/mcp/{server}/disconnect` | `mcp.disconnect` | | |
| [ ] 041 | `GET` | `/api/mcp/resource` | `mcp.resource.catalog` | | |
| [ ] 042 | `PATCH` | `/api/credential/{credentialID}` | `credential.update` | | |
| [ ] 043 | `DELETE` | `/api/credential/{credentialID}` | `credential.remove` | | |
| [ ] 044 | `POST` | `/api/credential/{credentialID}/activate` | `credential.activate` | | |
| [ ] 045 | `GET` | `/api/websearch/provider` | `websearch.providers` | | |
| [ ] 046 | `POST` | `/api/websearch` | `websearch.query` | | |
| [x] 036 | `GET` | `/api/mcp` | `mcp.list` | Keep | MCP inventory and connection status retained. |
| [x] 037 | `PUT` | `/api/experimental/mcp/{server}` | `experimental.mcp.add` | Experimental-only | Runtime-only MCP override; does not persist configuration. |
| [x] 038 | `DELETE` | `/api/experimental/mcp/{server}` | `experimental.mcp.remove` | Experimental-only | Runtime removal override; missing server returns `404`. |
| [x] 039 | `POST` | `/api/experimental/mcp/{server}/connect` | `experimental.mcp.connect` | Experimental-only | Runtime connection override retained outside the stable API. |
| [x] 040 | `POST` | `/api/experimental/mcp/{server}/disconnect` | `experimental.mcp.disconnect` | Experimental-only | Runtime disconnection override retained outside the stable API. |
| [ ] 041 | `GET` | `/api/mcp/resource` | `mcp.resource.catalog` | | Deferred for later review. |
| [x] 042 | `PATCH` | `/api/credential/{credentialID}` | `credential.update` | Change | Removed redundant location query; credentials and events are global. |
| [x] 043 | `DELETE` | `/api/credential/{credentialID}` | `credential.remove` | Change | Removed redundant location query; credentials and events are global. |
| [x] 044 | `POST` | `/api/credential/{credentialID}/activate` | `credential.activate` | Change | Removed redundant location query; credentials and events are global. |
| [x] 045 | `GET` | `/api/websearch/provider` | `websearch.providers` | Keep | Provider availability remains location-scoped; singular resource path retained. |
| [x] 046 | `POST` | `/api/websearch` | `websearch.query` | Keep | Unknown provider remains an invalid request; published time documented as Unix epoch milliseconds. |
## Group 4: Session lifecycle
@@ -138,13 +138,13 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 047 | `GET` | `/api/session` | `session.list` | | |
| [ ] 048 | `POST` | `/api/session` | `session.create` | | |
| [ ] 049 | `GET` | `/api/session/stats` | `session.stats` | | |
| [ ] 050 | `GET` | `/api/session/active` | `session.active` | | |
| [ ] 051 | `GET` | `/api/session/{sessionID}` | `session.get` | | |
| [ ] 052 | `DELETE` | `/api/session/{sessionID}` | `session.remove` | | |
| [ ] 053 | `POST` | `/api/session/{sessionID}/fork` | `session.fork` | | |
| [x] 047 | `GET` | `/api/session` | `session.list` | Keep | Existing filtering, ordering, and cursor contract retained for now. |
| [x] 048 | `POST` | `/api/session` | `session.create` | Keep | Existing creation contract retained; model reference includes optional variant. |
| [x] 049 | `GET` | `/api/experimental/session/stats` | `experimental.session.stats` | Experimental-only | Session analytics retained outside the stable API commitment. |
| [x] 050 | `GET` | `/api/session/active` | `session.active` | Keep | Status record retained for future active-state expansion. |
| [x] 051 | `GET` | `/api/session/{sessionID}` | `session.get` | Keep | Specific session read and typed `404` retained. |
| [x] 052 | `DELETE` | `/api/session/{sessionID}` | `session.remove` | Keep | Session and child deletion with typed `404` retained. |
| [x] 053 | `POST` | `/api/session/{sessionID}/fork` | `session.fork` | Change | Request now accepts optional branded `before` message ID; omission copies full history. |
| [ ] 054 | `POST` | `/api/session/{sessionID}/agent` | `session.switchAgent` | | |
| [ ] 055 | `POST` | `/api/session/{sessionID}/model` | `session.switchModel` | | |
| [ ] 056 | `POST` | `/api/session/{sessionID}/rename` | `session.rename` | | |
+16 -102
View File
@@ -14,6 +14,7 @@
"devDependencies": {
"@actions/artifact": "5.0.1",
"@ast-grep/cli": "0.44.0",
"@opencode/client": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@types/react": "19.2.17",
@@ -357,7 +358,7 @@
"@ff-labs/fff-bun": "0.10.5",
"@ff-labs/fff-node": "0.10.5",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
"@modelcontextprotocol/client": "2.0.0",
"@opencode-ai/pty": "0.1.13",
"@opencode/ai": "workspace:*",
"@opencode/codemode": "workspace:*",
@@ -394,6 +395,7 @@
"devDependencies": {
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@modelcontextprotocol/server": "2.0.0",
"@opencode/http-recorder": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
@@ -1081,8 +1083,8 @@
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
"@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
@@ -1877,8 +1879,6 @@
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
"@hono/node-server": ["@hono/node-server@1.19.17", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ=="],
"@hono/standard-validator": ["@hono/standard-validator@0.2.0", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-pFq0UVAnjzXcDAgqFpDeVL3MOUPrlIh/kPqBDvbCYoThVhhS+Vf37VcdsakdOFFGiqoiYVxp3LifXFhGhp/rgQ=="],
"@ibm/plex": ["@ibm/plex@6.4.1", "", { "dependencies": { "@ibm/telemetry-js": "^1.5.1" } }, "sha512-fnsipQywHt3zWvsnlyYKMikcVI7E2fEwpiPnIHFqlbByXVfQfANAAeJk1IV4mNnxhppUIDlhU0TzwYwL++Rn2g=="],
@@ -2037,7 +2037,11 @@
"@mermaid-js/parser": ["@mermaid-js/parser@1.2.1", "", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw=="],
"@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA=="],
"@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" } }, "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw=="],
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
@@ -3363,8 +3367,6 @@
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -3523,8 +3525,6 @@
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
"body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
@@ -3567,8 +3567,6 @@
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"bytestreamjs": ["bytestreamjs@2.0.1", "", {}, "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ=="],
"cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="],
@@ -3691,9 +3689,7 @@
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
@@ -3701,16 +3697,12 @@
"cookie-es": ["cookie-es@2.0.1", "", {}, "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"core-js": ["core-js@3.50.0", "", {}, "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw=="],
"core-js-compat": ["core-js-compat@3.50.0", "", { "dependencies": { "browserslist": "^4.28.7" } }, "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="],
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
@@ -3863,8 +3855,6 @@
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"deprecation": ["deprecation@2.3.1", "", {}, "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
@@ -3939,8 +3929,6 @@
"editorconfig": ["editorconfig@1.0.7", "", { "dependencies": { "@one-ini/wasm": "0.1.1", "commander": "^10.0.0", "minimatch": "^9.0.1", "semver": "^7.5.3" }, "bin": { "editorconfig": "bin/editorconfig" } }, "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -3979,8 +3967,6 @@
"emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"engine.io-client": ["engine.io-client@6.6.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.21.0", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q=="],
@@ -4033,8 +4019,6 @@
"escape-goat": ["escape-goat@4.0.0", "", {}, "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
@@ -4057,8 +4041,6 @@
"eta": ["eta@4.6.0", "", {}, "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
@@ -4077,10 +4059,6 @@
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@8.6.2", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A=="],
"expressive-code": ["expressive-code@0.41.7", "", { "dependencies": { "@expressive-code/core": "^0.41.7", "@expressive-code/plugin-frames": "^0.41.7", "@expressive-code/plugin-shiki": "^0.41.7", "@expressive-code/plugin-text-markers": "^0.41.7" } }, "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA=="],
"ext-list": ["ext-list@2.2.2", "", { "dependencies": { "mime-db": "^1.28.0" } }, "sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA=="],
@@ -4127,8 +4105,6 @@
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
@@ -4153,14 +4129,10 @@
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"framer-motion": ["framer-motion@8.5.5", "", { "dependencies": { "@motionone/dom": "^10.15.3", "hey-listen": "^1.0.8", "tslib": "^2.4.0" }, "optionalDependencies": { "@emotion/is-prop-valid": "^0.8.2" }, "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-5IDx5bxkjWHWUF3CVJoSyUVOtrbAxtzYBBowRE2uYI/6VYhkEBD+rbTHEGuUmbGHRj6YqqSfoG7Aa1cLyWCrBA=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
"fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="],
@@ -4335,8 +4307,6 @@
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"http-link-header": ["http-link-header@1.1.4", "", {}, "sha512-xT3GPW6/ZbGuw4UvwHqErSCEjNUlwbQJuZn9/q5U4WEKfp2kENVCAlousG1zLxHeaQ/ffOHUNpWamvkbBW0eNw=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
@@ -4391,8 +4361,6 @@
"ip-address": ["ip-address@10.5.0", "", {}, "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="],
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
@@ -4457,8 +4425,6 @@
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
@@ -4725,12 +4691,8 @@
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="],
"merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"mermaid": ["mermaid@11.17.2", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.34.0", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.21", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "fastdom": "1.0.12", "katex": "^0.16.47", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg=="],
@@ -4955,8 +4917,6 @@
"oidc-token-hash": ["oidc-token-hash@5.2.0", "", {}, "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
@@ -5027,8 +4987,6 @@
"parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"pascal-case": ["pascal-case@3.1.2", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
@@ -5151,8 +5109,6 @@
"protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
@@ -5179,10 +5135,6 @@
"radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="],
"range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"react": ["react@18.2.0", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ=="],
"react-dom": ["react-dom@18.2.0", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g=="],
@@ -5337,8 +5289,6 @@
"roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
@@ -5373,8 +5323,6 @@
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="],
"serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
@@ -5385,16 +5333,12 @@
"seroval-plugins": ["seroval-plugins@1.5.6", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
"set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
"set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@@ -5519,8 +5463,6 @@
"stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
@@ -5657,8 +5599,6 @@
"toad-cache": ["toad-cache@3.7.4", "", {}, "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.8", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-b+5ynEFp4Woe5a22hzNQm42lD23t13ZMihVxHbzjA50zdcM9aOSJTIjdJ0PDSd4/50HbBXcpHiQsz6rM4N88ww=="],
"topojson-client": ["topojson-client@3.1.0", "", { "dependencies": { "commander": "2" }, "bin": { "topo2geo": "bin/topo2geo", "topomerge": "bin/topomerge", "topoquantize": "bin/topoquantize" } }, "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw=="],
@@ -5701,8 +5641,6 @@
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
"typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="],
@@ -5781,8 +5719,6 @@
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
"unstorage": ["unstorage@2.0.0-alpha.7", "", { "peerDependencies": { "@azure/app-configuration": "^1.11.0", "@azure/cosmos": "^4.9.1", "@azure/data-tables": "^13.3.2", "@azure/identity": "^4.13.0", "@azure/keyvault-secrets": "^4.10.0", "@azure/storage-blob": "^12.31.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.13.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.36.2", "@vercel/blob": ">=0.27.3", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "chokidar": "^4 || ^5", "db0": ">=0.3.4", "idb-keyval": "^6.2.2", "ioredis": "^5.9.3", "lru-cache": "^11.2.6", "mongodb": "^6 || ^7", "ofetch": "*", "uploadthing": "^7.7.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "chokidar", "db0", "idb-keyval", "ioredis", "lru-cache", "mongodb", "ofetch", "uploadthing"] }, "sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog=="],
@@ -5813,8 +5749,6 @@
"validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.1.1", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.51", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.30" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-w3OHkuzzKZ3r2TOxER6myBYzZJNoDqol+DUHu3NnfBN/GETnUVxecZJab0CHQQ8GZc0jjzpFymepjcLDPS4SQg=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
@@ -6307,9 +6241,13 @@
"@mdx-js/mdx/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"@modelcontextprotocol/sdk/hono": ["hono@4.13.3", "", {}, "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw=="],
"@modelcontextprotocol/client/jose": ["jose@6.2.9", "", {}, "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA=="],
"@modelcontextprotocol/sdk/jose": ["jose@6.2.9", "", {}, "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA=="],
"@modelcontextprotocol/client/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@modelcontextprotocol/core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@modelcontextprotocol/server/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@npmcli/arborist/hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
@@ -6595,8 +6533,6 @@
"babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"boxen/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"builder-util/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
@@ -6675,8 +6611,6 @@
"esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"ffi-rs/@yuuang/ffi-rs-darwin-arm64": ["@yuuang/ffi-rs-darwin-arm64@1.3.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OueBlUFBT9IwD9pQnoYs0UszRBEySskfrEPXXfvKfGjL/DXnfn6kUheQ3oIP6sSmshVGNQUwrTCPo6feAa4QjA=="],
@@ -6811,8 +6745,6 @@
"roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"safe-array-concat/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"safe-push-apply/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
@@ -6867,8 +6799,6 @@
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
"type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"unplugin/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"unused-filename/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
@@ -7205,32 +7135,24 @@
"@octokit/auth-app/@octokit/request/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="],
"@octokit/auth-app/@octokit/request/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"@octokit/auth-app/@octokit/request-error/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="],
"@octokit/auth-oauth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.4", "", { "dependencies": { "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA=="],
"@octokit/auth-oauth-app/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.1", "", { "dependencies": { "@octokit/types": "^17.0.0" } }, "sha512-+eaY7G2VVpSf2pc5Gn1+mph837V/d/TYTJAgWL9Tb0ogGYcpN3IlAVFgjL+Vv93F/sevrxkvsYCedtpLdcFLzA=="],
"@octokit/auth-oauth-app/@octokit/request/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"@octokit/auth-oauth-app/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="],
"@octokit/auth-oauth-device/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.4", "", { "dependencies": { "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA=="],
"@octokit/auth-oauth-device/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.1", "", { "dependencies": { "@octokit/types": "^17.0.0" } }, "sha512-+eaY7G2VVpSf2pc5Gn1+mph837V/d/TYTJAgWL9Tb0ogGYcpN3IlAVFgjL+Vv93F/sevrxkvsYCedtpLdcFLzA=="],
"@octokit/auth-oauth-device/@octokit/request/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"@octokit/auth-oauth-device/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="],
"@octokit/auth-oauth-user/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.4", "", { "dependencies": { "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA=="],
"@octokit/auth-oauth-user/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.1", "", { "dependencies": { "@octokit/types": "^17.0.0" } }, "sha512-+eaY7G2VVpSf2pc5Gn1+mph837V/d/TYTJAgWL9Tb0ogGYcpN3IlAVFgjL+Vv93F/sevrxkvsYCedtpLdcFLzA=="],
"@octokit/auth-oauth-user/@octokit/request/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"@octokit/auth-oauth-user/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="],
"@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
@@ -7241,8 +7163,6 @@
"@octokit/oauth-methods/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.4", "", { "dependencies": { "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA=="],
"@octokit/oauth-methods/@octokit/request/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"@octokit/oauth-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="],
"@octokit/plugin-paginate-rest/@octokit/core/@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
@@ -8057,20 +7977,14 @@
"@octokit/plugin-paginate-rest/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.4", "", { "dependencies": { "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA=="],
"@octokit/plugin-paginate-rest/@octokit/core/@octokit/request/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"@octokit/plugin-paginate-rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.4", "", { "dependencies": { "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/request/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="],
"@octokit/rest/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.4", "", { "dependencies": { "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA=="],
"@octokit/rest/@octokit/core/@octokit/request/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="],
"@opencode/enterprise/@tailwindcss/vite/@tailwindcss/node/lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
+1 -1
View File
@@ -90,7 +90,7 @@ stdenv.mkDerivation (finalAttrs: {
export OPENCODE_CLI_DIST="$TMPDIR/desktop-cli"
cli_package=$(bun -e 'import { getCurrentCli } from "./scripts/utils.ts"; console.log(getCurrentCli().package.replace("@opencode/", ""))')
mkdir -p "$OPENCODE_CLI_DIST/$cli_package/bin"
cp ${lib.getExe opencode} "$OPENCODE_CLI_DIST/$cli_package/bin/opencode2"
cp ${lib.getExe opencode} "$OPENCODE_CLI_DIST/$cli_package/bin/opencode"
bun run build
npx electron-builder --dir \
+14 -4
View File
@@ -8,6 +8,7 @@
makeBinaryWrapper,
models-dev,
ripgrep,
wayland,
installShellFiles,
versionCheckHook,
writableTmpDirAsHomeHook,
@@ -62,9 +63,10 @@ stdenvNoCC.mkDerivation (finalAttrs: {
installPhase = ''
runHook preInstall
install -Dm755 dist/cli-*/bin/opencode2 $out/bin/opencode2
install -Dm755 dist/cli-*/bin/opencode $out/bin/opencode
wrapProgram $out/bin/opencode2 \
# OpenTUI dlopens Wayland for clipboard images.
wrapProgram $out/bin/opencode \
--prefix PATH : ${
lib.makeBinPath (
[
@@ -73,13 +75,21 @@ stdenvNoCC.mkDerivation (finalAttrs: {
# bun runs sysctl to detect if running on rosetta2
++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl
)
}
} ${lib.optionalString stdenvNoCC.hostPlatform.isLinux ''
--prefix LD_LIBRARY_PATH : ${lib.makeLibraryPath [ wayland ]}
''}
ln -s opencode $out/bin/opencode2
runHook postInstall
'';
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
# trick yargs into also generating zsh completions
installShellCompletion --cmd opencode \
--bash <($out/bin/opencode completion) \
--zsh <(SHELL=/bin/zsh $out/bin/opencode completion)
installShellCompletion --cmd opencode2 \
--bash <($out/bin/opencode2 completion) \
--zsh <(SHELL=/bin/zsh $out/bin/opencode2 completion)
@@ -101,7 +111,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
description = "The open source coding agent";
homepage = "https://opencode.ai";
license = lib.licenses.mit;
mainProgram = "opencode2";
mainProgram = "opencode";
inherit (node_modules.meta) platforms;
};
})
+3 -2
View File
@@ -116,6 +116,7 @@
"devDependencies": {
"@actions/artifact": "5.0.1",
"@ast-grep/cli": "0.44.0",
"@opencode/client": "workspace:*",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@tsconfig/bun": "catalog:",
@@ -175,10 +176,10 @@
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
"vite@8.2.2": "patches/vite@8.2.2.patch"
"vite@8.2.2": "patches/vite@8.2.2.patch",
"@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch"
}
}
+1 -1
View File
@@ -522,7 +522,7 @@ const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenRes
call_id: part.id,
name: part.name,
namespace: part.namespace,
arguments: ProviderShared.encodeJson(part.input),
arguments: ProviderShared.encodeJson(part.input === undefined ? {} : part.input),
}
}
+6 -2
View File
@@ -315,7 +315,7 @@ const lowerToolCall = (part: ToolCallPart, options: LoweringOptions): OpenAIChat
type: "function",
function: {
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
arguments: ProviderShared.encodeJson(part.input === undefined ? {} : part.input),
},
})
@@ -323,7 +323,11 @@ const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart
const media = ProviderShared.normalizeMedia(part)
if (!media.mime.startsWith("image/"))
return yield* ProviderShared.invalidRequest(`OpenAI Chat does not support media type ${part.mediaType}`)
return { type: "image_url" as const, image_url: { url: media.dataUrl } }
const url =
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
? part.data
: media.dataUrl
return { type: "image_url" as const, image_url: { url } }
})
const openAICompatibleReasoningContent = (native: unknown) =>
+5 -1
View File
@@ -37,7 +37,7 @@ export type Settings = ProviderPackage.Settings &
}
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
const body = yield* Gemini.protocol.body.from(request)
const { serviceTier: _, ...body } = yield* Gemini.protocol.body.from(request)
// Vertex's native REST schema rejects `id` on FunctionCall/FunctionResponse parts with HTTP 400,
// unlike AI Studio, so history minted there cannot be lowered verbatim.
const contents = body.contents.map((content) => ({
@@ -75,6 +75,10 @@ const route = Route.make({
return `/${model.startsWith("endpoints/") ? model : `models/${model}`}:streamGenerateContent?alt=sse`
}),
auth: Auth.none,
headers: ({ request }): Record<string, string> => {
const serviceTier = request.providerOptions?.serviceTier
return typeof serviceTier === "string" ? { "x-vertex-ai-llm-shared-request-type": serviceTier } : {}
},
framing: Framing.sse,
})
@@ -75,6 +75,45 @@ describe("Google Vertex providers", () => {
}),
)
it.effect("maps service tiers to the Vertex shared PayGo header", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: GoogleVertex.configure({
accessToken: "vertex-token",
location: "global",
project: "vertex-project",
providerOptions: { serviceTier: "flex" },
}).model("gemini-2.5-flash"),
prompt: "Say hello.",
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(request.headers.get("x-vertex-ai-llm-shared-request-type")).toBe("flex")
expect(yield* Effect.promise(() => request.json())).not.toHaveProperty("serviceTier")
return input.respond(
sseEvents({
candidates: [
{
content: { role: "model", parts: [{ text: "Hello." }] },
finishReason: "STOP",
},
],
}),
{ headers: { "content-type": "text/event-stream" } },
)
}),
),
),
)
expect(response.text).toBe("Hello.")
}),
)
it.effect("strips function call ids Vertex does not accept from lowered bodies", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -0,0 +1,73 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, Message, ToolCallPart } from "../../src/index.js"
import { OpenAIChat } from "../../src/protocols/openai-chat.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { Auth } from "../../src/route/auth.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
for (const route of [OpenAIChat.route, OpenAIResponses.route]) {
const model = route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4.1-mini" })
const chat = route.id === "openai-chat"
it.effect(`${route.id} serializes schema-valid undefined historical tool input as an empty object`, () =>
Effect.gen(function* () {
const message = Schema.decodeUnknownSync(Message)({
role: "assistant",
content: [{ type: "tool-call", id: "call_1", name: "lookup", input: undefined }],
})
expect(Schema.is(Message)(message)).toBe(true)
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Look up the value."),
message,
Message.tool({ id: "call_1", name: "lookup", result: "Missing input", resultType: "error" }),
],
}),
)
if (chat)
expect(prepared.body.messages).toContainEqual({
role: "assistant",
content: null,
tool_calls: [{ id: "call_1", type: "function", function: { name: "lookup", arguments: "{}" } }],
})
if (!chat)
expect(prepared.body.input).toContainEqual({
type: "function_call",
call_id: "call_1",
name: "lookup",
arguments: "{}",
})
expect(message.content[0]).toEqual({ type: "tool-call", id: "call_1", name: "lookup", input: undefined })
}),
)
it.effect(`${route.id} preserves defined historical tool inputs`, () =>
Effect.gen(function* () {
for (const [input, encoded] of [
[null, "null"],
[[], "[]"],
[42, "42"],
["invalid", '"invalid"'],
[{ value: "original" }, '{"value":"original"}'],
] as const) {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input })]),
Message.tool({ id: "call_1", name: "lookup", result: "Invalid input", resultType: "error" }),
],
}),
)
if (chat) expect(prepared.body.messages[0].tool_calls[0].function.arguments).toBe(encoded)
if (!chat) expect(prepared.body.input[0].arguments).toBe(encoded)
}
}),
)
}
@@ -698,6 +698,54 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves HTTP and HTTPS image URLs in user content", () =>
Effect.gen(function* () {
const urls = ["https://example.com/image.png?size=64#preview", "http://example.com/image.jpg"]
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: urls.map((data) => ({ type: "media" as const, mediaType: "image/png", data })),
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: urls.map((url) => ({ type: "image_url", image_url: { url } })) },
])
}),
)
it.effect("preserves remote image URLs from tool results", () =>
Effect.gen(function* () {
const url = "https://example.com/tool-image.png?version=2"
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Describe the image."),
Message.assistant([ToolCallPart.make({ id: "call_image", name: "read_image", input: {} })]),
Message.tool({
id: "call_image",
name: "read_image",
resultType: "content",
result: [
{ type: "text", text: "Image attached." },
{ type: "file", mime: "image/png", uri: url },
],
}),
],
}),
)
expect(prepared.body.messages).toContainEqual({
role: "tool",
tool_call_id: "call_image",
content: "Image attached.",
})
expect(prepared.body.messages.at(-1)).toEqual({
role: "user",
content: [{ type: "image_url", image_url: { url } }],
})
}),
)
it.effect("rejects non-image media that cannot be lowered", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
@@ -30,16 +30,16 @@ for (const shared of [true, false]) {
sessions: [{ id: sessionID, projectID, directory: workspace, title }],
pageMessages: () => ({ items: [] }),
})
await page.route("**/api/mcp**", async (route) => {
await page.route(/\/api\/(?:experimental\/)?mcp(?:[/?]|$)/, async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
const target = url.searchParams.get("location[directory]") ?? directory
requests.push({ path: url.pathname, directory: target })
if (url.pathname === "/api/mcp/figma-desktop/connect") {
if (url.pathname === "/api/experimental/mcp/figma-desktop/connect") {
connected.add(target)
return route.fulfill({ status: 204 })
}
if (url.pathname === "/api/mcp/figma-desktop/disconnect") {
if (url.pathname === "/api/experimental/mcp/figma-desktop/disconnect") {
connected.delete(target)
return route.fulfill({ status: 204 })
}
@@ -72,7 +72,7 @@ for (const shared of [true, false]) {
await expect(toggle).toBeChecked()
await expect(toggle).toBeEnabled()
expect(connected).toEqual(new Set([workspace]))
expect(requests).toContainEqual({ path: "/api/mcp/figma-desktop/connect", directory: workspace })
expect(requests).toContainEqual({ path: "/api/experimental/mcp/figma-desktop/connect", directory: workspace })
expect(requests).toContainEqual({ path: "/api/mcp/resource", directory: workspace })
expect(requests.every((request) => request.directory === workspace)).toBe(true)
await testInfo.attach("workspace-connected", { body: await page.screenshot(), contentType: "image/png" })
@@ -82,7 +82,7 @@ for (const shared of [true, false]) {
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
expect(connected.size).toBe(0)
expect(requests).toContainEqual({ path: "/api/mcp/figma-desktop/disconnect", directory: workspace })
expect(requests).toContainEqual({ path: "/api/experimental/mcp/figma-desktop/disconnect", directory: workspace })
expect(requests.every((request) => request.directory === workspace)).toBe(true)
})
}
@@ -109,16 +109,16 @@ for (const surface of ["popover", "dialog"] as const) {
sessions: [{ id: sessionID, projectID, directory: workspace, title }],
pageMessages: () => ({ items: [] }),
})
await page.route("**/api/mcp**", async (route) => {
await page.route(/\/api\/(?:experimental\/)?mcp(?:[/?]|$)/, async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
const target = url.searchParams.get("location[directory]") ?? directory
requests.push({ path: url.pathname, directory: target })
if (url.pathname === "/api/mcp/figma-desktop/disconnect") {
if (url.pathname === "/api/experimental/mcp/figma-desktop/disconnect") {
state.status = "disabled"
return route.fulfill({ status: 204 })
}
if (url.pathname === "/api/mcp/figma-desktop/connect") {
if (url.pathname === "/api/experimental/mcp/figma-desktop/connect") {
state.status = state.fail ? "failed" : "connected"
// Connection failures are reported by the refreshed status, not the HTTP response.
return route.fulfill({ status: 204 })
@@ -167,7 +167,7 @@ for (const surface of ["popover", "dialog"] as const) {
await expect(toggle).toBeChecked({ checked: surface === "popover" })
await expect(toggle).toBeEnabled()
expect(requests.filter((request) => request.path.endsWith("/connect"))).toEqual([
{ path: "/api/mcp/figma-desktop/connect", directory: workspace },
{ path: "/api/experimental/mcp/figma-desktop/connect", directory: workspace },
])
expect(requests.every((request) => request.directory === workspace)).toBe(true)
await expect(toast).toHaveCSS("opacity", "1")
@@ -343,7 +343,7 @@ async function openDraft(page: Page, worktree = "main", options: { git?: boolean
(route) => route.fulfill({ json: { location: { directory }, data: { branch: {} } } }),
)
}
await page.route("**/api/mcp**", async (route) => {
await page.route(/\/api\/(?:experimental\/)?mcp(?:[/?]|$)/, async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
const target = url.searchParams.get("location[directory]") ?? directory
@@ -0,0 +1,212 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
import { createTwoFilesPatch } from "diff"
import {
assistantMessage,
setupTimeline,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("")
const after = before.replaceAll(" = ", " = 1 + ")
const files = ["src/a.ts", "src/b.ts"].map((file) => ({
file,
status: "modified",
additions: 80,
deletions: 80,
patch: createTwoFilesPatch(file, file, before, after),
}))
const scenarios = [
{
name: "grouped patch",
placement: "grouped",
tools: [toolPart("prt_sticky_patch", "patch", "completed", {}, { metadata: { files } })],
files: ["a", "b"],
title: false,
},
{
name: "standalone patch",
placement: "separate",
tools: [toolPart("prt_sticky_patch", "patch", "completed", {}, { metadata: { files } })],
files: ["a", "b"],
title: false,
},
{
name: "grouped edit with title",
placement: "grouped",
tools: [
toolPart(
"prt_sticky_edit",
"edit",
"completed",
{ path: "src/a.ts", oldString: before, newString: after },
{ metadata: { files: [files[0]] } },
),
],
files: ["a"],
title: true,
},
{
name: "running edit input fallback",
placement: "grouped",
tools: [toolPart("prt_sticky_edit", "edit", "running", { path: "src/a.ts", oldString: before, newString: after })],
files: ["a"],
title: true,
},
{
name: "grouped write",
placement: "grouped",
tools: [toolPart("prt_sticky_write", "write", "completed", { path: "src/a.ts", content: after })],
files: ["a"],
title: false,
},
{
name: "running write input fallback",
placement: "grouped",
tools: [toolPart("prt_sticky_write", "write", "running", { path: "src/a.ts", content: after })],
files: ["a"],
title: false,
},
{
name: "merged edit write and patch",
placement: "separate",
tools: [
toolPart("prt_sticky_edit", "edit", "completed", {}, { metadata: { files: [files[0]] } }),
toolPart("prt_sticky_write", "write", "completed", { path: "src/b.ts", content: after }),
toolPart(
"prt_sticky_patch",
"patch",
"completed",
{},
{ metadata: { files: [{ ...files[0], file: "src/c.ts" }] } },
),
],
files: ["a", "b", "c"],
title: false,
},
{
name: "created and deleted patch files",
placement: "grouped",
tools: [
toolPart(
"prt_sticky_patch",
"patch",
"completed",
{},
{
metadata: {
files: [
{
file: "src/a.ts",
status: "added",
additions: 80,
deletions: 0,
patch: createTwoFilesPatch("src/a.ts", "src/a.ts", "", after),
},
{
file: "src/b.ts",
status: "deleted",
additions: 0,
deletions: 80,
patch: createTwoFilesPatch("src/b.ts", "src/b.ts", before, ""),
},
],
},
},
),
],
files: ["a", "b"],
title: false,
},
] as const
for (const scenario of scenarios) {
for (const width of [1400, 390]) {
for (const direction of ["ltr", "rtl"]) {
test(`${scenario.name}: file headers stay flush at ${width}px in ${direction}`, async ({ page }, info) => {
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([...scenario.tools, textPart("prt_after_patch", "Following explanation.\n\n".repeat(60))]),
],
settings: {
timelineDetail: {
...timelinePresets[2].value,
edit: { placement: scenario.placement, details: "collapsed" },
},
},
reducedMotion: true,
viewport: { width, height: 900 },
})
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
if (scenario.placement === "grouped") {
await page.locator('[data-component="context-tool-group-trigger"]').click()
}
const patch = page.locator('[data-scope="apply-patch"]')
await expect(patch).toHaveCount(1)
const scroller = page.locator('[data-slot="session-timeline-scroll"] .scroll-view__viewport')
const toolTitle = scroller.locator('[data-slot="collapsible-trigger"][data-locked]')
await expect(toolTitle).toHaveCount(scenario.title ? 1 : 0)
await expect(scroller.locator("[data-session-title]")).toHaveCount(width === 1400 ? 1 : 0)
for (const file of scenario.files) {
const name = new RegExp(`${file}\\.ts`)
const trigger = patch.getByRole("button", { name })
const header = patch.getByRole("heading", { name })
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
const content = patch.getByRole("region", { name })
await expect
.poll(() => content.evaluate((element) => element.getBoundingClientRect().height))
.toBeGreaterThan(900)
// Leave follow-latest mode before positioning the viewport inside this file.
await scroller.hover()
await page.mouse.wheel(0, -100)
await content.evaluate((element) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!
viewport.scrollTop += element.getBoundingClientRect().top - viewport.getBoundingClientRect().top + 160
})
await expect
.poll(() =>
content.evaluate((element) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!
return element.getBoundingClientRect().top - viewport.getBoundingClientRect().top
}),
)
.toBeLessThan(0)
await expect
.poll(() =>
header.evaluate((element) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!
const title = viewport.querySelector("[data-session-title]")?.firstElementChild
const toolTitle = element
.closest('[data-component="edit-tool"]')
?.querySelector('[data-slot="collapsible-trigger"][data-locked]')
const top = viewport.getBoundingClientRect().top + (title?.getBoundingClientRect().height ?? 0)
const rect = element.getBoundingClientRect()
const trigger = element.querySelector("button")!
return {
gap: Math.abs(rect.top - top - (toolTitle?.getBoundingClientRect().height ?? 0)),
titleGap: toolTitle ? Math.abs(toolTitle.getBoundingClientRect().top - top) : 0,
clickable: trigger.contains(
document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2),
),
}
}),
)
.toEqual({ gap: 0, titleGap: 0, clickable: true })
await page.screenshot({ path: info.outputPath(`${file}.png`) })
}
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
await expect(
patch.getByRole("heading", { name: new RegExp(`${scenario.files.at(-1)}\\.ts`) }),
).not.toBeInViewport()
})
}
}
}
@@ -6,7 +6,7 @@ test("every MCP row hit area toggles exactly once and keeps the submenu open", a
await mockStressTimeline(page)
const state = { enabled: true }
const writes: string[] = []
await page.route("**/api/mcp**", (route) => {
await page.route(/\/api\/(?:experimental\/)?mcp(?:[/?]|$)/, (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
const directory = url.searchParams.get("location[directory]")
@@ -62,7 +62,7 @@ test("every MCP row hit area toggles exactly once and keeps the submenu open", a
await expect(submenu).toBeVisible()
if (target === "keyboard") await expect(toggle).toBeFocused()
expect(writes).toHaveLength(index + 1)
expect(writes[index]).toBe(`/api/mcp/figma/${enabled ? "connect" : "disconnect"}`)
expect(writes[index]).toBe(`/api/experimental/mcp/figma/${enabled ? "connect" : "disconnect"}`)
}
})
@@ -72,7 +72,7 @@ test("MCP authentication starts before a slow resource catalog finishes", async
const attempts: string[] = []
const resources = Promise.withResolvers<void>()
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
await page.route("**/api/mcp**", async (route) => {
await page.route(/\/api\/(?:experimental\/)?mcp(?:[/?]|$)/, async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
if (url.pathname.endsWith("/connect")) {
@@ -113,7 +113,7 @@ test("passes through non-event fetches", async ({ page }) => {
return response.json()
})
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: ["http://localhost"] })
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: [] })
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
})
@@ -96,7 +96,7 @@ test("single-server settings expose scoped pages without a server picker", async
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/experimental/config",
)
await page.getByRole("option", { name: "bash", exact: true }).click()
expect((await updated).postDataJSON()).toEqual({ shell: "/bin/bash" })
expect((await updated).postDataJSON()).toEqual({ shell: "bash" })
})
test("project settings open as a nested autosaving view", async ({ page }) => {
@@ -335,7 +335,7 @@ fixture("upgrades the legacy shared precache only after old tabs close", async (
fixture("does not substitute cached HTML for API or missing asset navigations", async ({ page, site }) => {
await install(page, site.url)
const api = await page.goto(`${site.url}/api/status`)
expect(await api?.json()).toEqual({ version: "test", pid: 1, urls: [site.url] })
expect(await api?.json()).toEqual({ version: "test", pid: 1, urls: ["http://localhost"] })
expect(api?.fromServiceWorker()).toBe(false)
const asset = await page.goto(`${site.url}/_assets/missing.js`)
expect(asset?.status()).toBe(404)
+1 -1
View File
@@ -576,7 +576,7 @@ export const dict = {
"context.stats.lastActivity": "Last Activity",
"context.usage.tokens": "Tokens",
"context.usage.usage": "Context Usage",
"context.usage.usage": "Context",
"context.usage.cost": "Cost",
"context.usage.clickToView": "Click to view context",
"context.usage.view": "View context usage",
@@ -69,7 +69,7 @@ export const DialogFork: Component = () => {
const dir = base64Encode(location().directory)
serverSDK.api.session
.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
.fork({ sessionID, before: item.id })
.then((forked) => {
data.session.remember(forked)
dialog.close()
@@ -105,7 +105,7 @@ export const SettingsProviders: Component<{
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
await Promise.all(
credentials.map((credential) => serverSdk.api.credential.remove({ credentialID: credential.id, location })),
credentials.map((credential) => serverSdk.api.credential.remove({ credentialID: credential.id })),
)
showToast({
variant: "success",
-1
View File
@@ -263,7 +263,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
forkSession: async (params) => {
const forked = await input.client.session.fork({
sessionID: params.sessionId,
boundary: { type: "through" },
})
const state = await attach(forked, forked.location.directory, params.mcpServers ?? [])
await replay(state)
@@ -3,7 +3,7 @@ import { Effect, Option } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { handlePromptErrors, requireInteractive } from "../../../ui/prompt"
import { createClient, loadIntegrations, location, request } from "./shared"
import { createClient, loadIntegrations, request } from "./shared"
import { chooseCredential, chooseIntegration } from "./account"
export default Runtime.handler(
@@ -35,7 +35,7 @@ const logout = Effect.fn("cli.auth.logout.run")(function* (input: {
const credentialID = yield* chooseCredential(integration, "log out", input.credential)
const progress = spinner()
progress.start("Removing credential...")
yield* request((signal) => client.credential.remove({ credentialID, location }, { signal })).pipe(
yield* request((signal) => client.credential.remove({ credentialID }, { signal })).pipe(
Effect.tap(() => Effect.sync(() => progress.stop(`Removed account from ${integration.name}`))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Failed to remove credential", 1))),
)
@@ -3,7 +3,7 @@ import { Effect, Option } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { handlePromptErrors, requireInteractive } from "../../../ui/prompt"
import { createClient, loadIntegrations, location, request } from "./shared"
import { createClient, loadIntegrations, request } from "./shared"
import { chooseCredential, chooseIntegration } from "./account"
export default Runtime.handler(
@@ -35,7 +35,7 @@ const switchAccount = Effect.fn("cli.auth.switch.run")(function* (input: {
const credentialID = yield* chooseCredential(integration, "switch to", input.credential)
const progress = spinner()
progress.start("Switching account...")
yield* request((signal) => client.credential.activate({ credentialID, location }, { signal })).pipe(
yield* request((signal) => client.credential.activate({ credentialID }, { signal })).pipe(
Effect.tap(() => Effect.sync(() => progress.stop(`Switched account for ${integration.name}`))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Failed to switch account", 1))),
)
@@ -29,7 +29,7 @@ export default Runtime.handler(
yield* Effect.forEach(
credentials,
(connection) => Effect.promise(() => client.credential.remove({ credentialID: connection.id, location })),
(connection) => Effect.promise(() => client.credential.remove({ credentialID: connection.id })),
{ discard: true },
)
process.stdout.write(`Removed OAuth credentials for ${input.name}` + EOL)
+2 -2
View File
@@ -112,7 +112,7 @@ async function selectSession(input: {
return {
session: input.fork
? await input.client.session
.fork({ sessionID: explicit.id, boundary: { type: "through" } }, ...requestOptions(input.signal))
.fork({ sessionID: explicit.id }, ...requestOptions(input.signal))
.catch((error) => {
throw new SessionTargetMutationError(error)
})
@@ -126,7 +126,7 @@ async function selectSession(input: {
return {
session: input.fork
? await input.client.session
.fork({ sessionID: selected.id, boundary: { type: "through" } }, ...requestOptions(input.signal))
.fork({ sessionID: selected.id }, ...requestOptions(input.signal))
.catch((error) => {
throw new SessionTargetMutationError(error)
})
@@ -236,6 +236,7 @@ describe("acp service directory behavior", () => {
headers: [{ name: "Authorization", value: "Bearer x" }],
}
let created = 0
const mcp = "/api/experimental/mcp/"
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
@@ -245,7 +246,7 @@ describe("acp service directory behavior", () => {
if (request.method === "GET" && request.path === "/api/session/ses_1") {
return Response.json({ data: makeSession("ses_1") })
}
if (request.method === "PUT" && request.path.startsWith("/api/mcp/")) {
if (request.method === "PUT" && request.path.startsWith(mcp)) {
return new Response(null, { status: 204 })
}
return undefined
@@ -257,9 +258,9 @@ describe("acp service directory behavior", () => {
await fixture.service.resumeSession({ cwd: "/workspace", sessionId: "ses_1", mcpServers: [changed] })
await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local] })
const adds = fixture.requests.filter((request) => request.method === "PUT" && request.path.startsWith("/api/mcp/"))
const adds = fixture.requests.filter((request) => request.method === "PUT" && request.path.startsWith(mcp))
expect(adds).toHaveLength(4)
expect(adds.filter((request) => request.path === "/api/mcp/tools").map((request) => request.body)).toEqual([
expect(adds.filter((request) => request.path === `${mcp}tools`).map((request) => request.body)).toEqual([
{
config: {
type: "local",
@@ -282,7 +283,7 @@ describe("acp service directory behavior", () => {
},
},
])
expect(adds.find((request) => request.path === "/api/mcp/docs")?.body).toEqual({
expect(adds.find((request) => request.path === `${mcp}docs`)?.body).toEqual({
config: {
type: "remote",
url: "https://example.com/mcp",
@@ -168,7 +168,7 @@ describe("acp service lifecycle", () => {
method: "POST",
path: "/api/session/ses_loaded/fork",
query: {},
body: { boundary: { type: "through" } },
body: {},
})
})
+3 -2
View File
@@ -24,7 +24,8 @@ describe("acp service", () => {
if (url.pathname === "/api/command")
return Response.json({ location, data: [{ name: "review", template: "" }] })
if (url.pathname === "/api/session" && request.method === "POST") return Response.json({ data: session })
if (url.pathname === "/api/mcp/docs" && request.method === "PUT") return new Response(null, { status: 204 })
if (url.pathname === "/api/experimental/mcp/docs" && request.method === "PUT")
return new Response(null, { status: 204 })
return new Response(null, { status: 404 })
},
})
@@ -53,7 +54,7 @@ describe("acp service", () => {
expect(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"])
expect(requests).toContainEqual({
method: "PUT",
path: "/api/mcp/docs",
path: "/api/experimental/mcp/docs",
body: {
config: { type: "local", command: ["bun", "docs.ts"], environment: { TOKEN: "x" } },
},
+3 -1
View File
@@ -68,6 +68,7 @@ export async function createAcpFixture(options: { readonly skill?: string } = {}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cli-acp-"))
const home = path.join(root, "workspace")
const config = path.join(root, "config")
const models = path.join(root, "models.json")
const skills = path.join(root, "skills")
await Promise.all([fs.mkdir(home, { recursive: true }), fs.mkdir(config, { recursive: true })])
if (options.skill) {
@@ -93,6 +94,7 @@ export async function createAcpFixture(options: { readonly skill?: string } = {}
path.join(config, "opencode.json"),
JSON.stringify(verifierConfig(`http://127.0.0.1:${llm.port}/v1`, options.skill ? skills : undefined)),
)
await Bun.write(models, "{}")
const processes = new Set<AcpProcess>()
return {
@@ -106,7 +108,7 @@ export async function createAcpFixture(options: { readonly skill?: string } = {}
OPENCODE_CONFIG: undefined,
OPENCODE_CONFIG_CONTENT: undefined,
OPENCODE_DISABLE_AUTOUPDATE: "true",
OPENCODE_MODELS_PATH: undefined,
OPENCODE_MODELS_PATH: models,
...extraEnv,
}),
})
+4 -14
View File
@@ -217,7 +217,7 @@ export type SessionRemoveInput = { readonly sessionID: Session.ID }
export type SessionRemoveOutput = void
export type SessionRemoveOperation<E = never> = (input: SessionRemoveInput) => Effect.Effect<SessionRemoveOutput, E>
export type SessionForkInput = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
export type SessionForkInput = { readonly sessionID: Session.ID; readonly before?: SessionMessage.ID | undefined }
export type SessionForkOutput = Session.Info
export type SessionForkOperation<E = never> = (input: SessionForkInput) => Effect.Effect<SessionForkOutput, E>
@@ -1641,29 +1641,19 @@ export interface McpApi<E = never> {
readonly resource: { readonly catalog: McpResourceCatalogOperation<E> }
}
export type CredentialUpdateInput = {
readonly credentialID: Credential.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly label: string
}
export type CredentialUpdateInput = { readonly credentialID: Credential.ID; readonly label: string }
export type CredentialUpdateOutput = void
export type CredentialUpdateOperation<E = never> = (
input: CredentialUpdateInput,
) => Effect.Effect<CredentialUpdateOutput, E>
export type CredentialActivateInput = {
readonly credentialID: Credential.ID
readonly location?: { readonly directory?: string | undefined } | undefined
}
export type CredentialActivateInput = { readonly credentialID: Credential.ID }
export type CredentialActivateOutput = void
export type CredentialActivateOperation<E = never> = (
input: CredentialActivateInput,
) => Effect.Effect<CredentialActivateOutput, E>
export type CredentialRemoveInput = {
readonly credentialID: Credential.ID
readonly location?: { readonly directory?: string | undefined } | undefined
}
export type CredentialRemoveInput = { readonly credentialID: Credential.ID }
export type CredentialRemoveOutput = void
export type CredentialRemoveOperation<E = never> = (
input: CredentialRemoveInput,
+5 -10
View File
@@ -430,7 +430,7 @@ const EndpointSessionRemove = (raw: RawClient["server.session"]) => (input: Sess
const EndpointSessionFork = (raw: RawClient["server.session"]) => (input: SessionForkInput) =>
preserveEffect<SessionForkOutput>()(
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { before: input["before"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -969,25 +969,20 @@ const EndpointCredentialUpdate = (raw: RawClient["server.credential"]) => (input
preserveEffect<CredentialUpdateOutput>()(
raw["credential.update"]({
params: { credentialID: input["credentialID"] },
query: { location: input["location"] },
payload: { label: input["label"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointCredentialActivate = (raw: RawClient["server.credential"]) => (input: CredentialActivateInput) =>
preserveEffect<CredentialActivateOutput>()(
raw["credential.activate"]({
params: { credentialID: input["credentialID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError)),
raw["credential.activate"]({ params: { credentialID: input["credentialID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointCredentialRemove = (raw: RawClient["server.credential"]) => (input: CredentialRemoveInput) =>
preserveEffect<CredentialRemoveOutput>()(
raw["credential.remove"]({
params: { credentialID: input["credentialID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError)),
raw["credential.remove"]({ params: { credentialID: input["credentialID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupCredential = (raw: RawClient["server.credential"]) => ({
+11 -14
View File
@@ -517,7 +517,7 @@ export function make(options: ClientOptions) {
request<{ readonly data: SessionStatsOutput }>(
{
method: "GET",
path: `/api/session/stats`,
path: `/api/experimental/session/stats`,
query: {
from: input?.["from"],
to: input?.["to"],
@@ -613,7 +613,7 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/fork`,
body: { boundary: input["boundary"] },
body: { before: input["before"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
@@ -1129,7 +1129,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { key: input["key"], answer: input["answer"], label: input["label"] },
successStatus: 204,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -1156,7 +1156,7 @@ export function make(options: ClientOptions) {
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1169,7 +1169,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { code: input["code"] },
successStatus: 204,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -1196,7 +1196,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { methodID: input["methodID"], label: input["label"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1208,7 +1208,7 @@ export function make(options: ClientOptions) {
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/command/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1244,7 +1244,7 @@ export function make(options: ClientOptions) {
request<McpAddOutput>(
{
method: "PUT",
path: `/api/mcp/${encodeURIComponent(input.server)}`,
path: `/api/experimental/mcp/${encodeURIComponent(input.server)}`,
query: { location: input["location"] },
body: { config: input["config"] },
successStatus: 204,
@@ -1257,7 +1257,7 @@ export function make(options: ClientOptions) {
request<McpRemoveOutput>(
{
method: "DELETE",
path: `/api/mcp/${encodeURIComponent(input.server)}`,
path: `/api/experimental/mcp/${encodeURIComponent(input.server)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
@@ -1269,7 +1269,7 @@ export function make(options: ClientOptions) {
request<McpConnectOutput>(
{
method: "POST",
path: `/api/mcp/${encodeURIComponent(input.server)}/connect`,
path: `/api/experimental/mcp/${encodeURIComponent(input.server)}/connect`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
@@ -1281,7 +1281,7 @@ export function make(options: ClientOptions) {
request<McpDisconnectOutput>(
{
method: "POST",
path: `/api/mcp/${encodeURIComponent(input.server)}/disconnect`,
path: `/api/experimental/mcp/${encodeURIComponent(input.server)}/disconnect`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
@@ -1310,7 +1310,6 @@ export function make(options: ClientOptions) {
{
method: "PATCH",
path: `/api/credential/${encodeURIComponent(input.credentialID)}`,
query: { location: input["location"] },
body: { label: input["label"] },
successStatus: 204,
declaredStatuses: [400, 401],
@@ -1323,7 +1322,6 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/credential/${encodeURIComponent(input.credentialID)}/activate`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
@@ -1335,7 +1333,6 @@ export function make(options: ClientOptions) {
{
method: "DELETE",
path: `/api/credential/${encodeURIComponent(input.credentialID)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
+27 -12
View File
@@ -422,6 +422,8 @@ export type WebSearchProvider = { id: string; name: string }
export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } }
export type McpProtocol = "legacy" | "auto" | "2026-07-28"
export type ConfigWorktree = { directory: string }
export type ConfigShellOption = { path: string; name: string; acceptable: boolean }
@@ -1990,6 +1992,7 @@ export type ConfigEntry =
disabled?: boolean
codemode?: boolean
timeout?: { startup?: number; catalog?: number; execution?: number }
protocol?: McpProtocol
}
| {
type: "remote"
@@ -2007,6 +2010,7 @@ export type ConfigEntry =
disabled?: boolean
codemode?: boolean
timeout?: { startup?: number; catalog?: number; execution?: number }
protocol?: McpProtocol
}
}
}
@@ -2510,6 +2514,24 @@ export type IntegrationNotFoundError = {
export const isIntegrationNotFoundError = (value: unknown): value is IntegrationNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "IntegrationNotFoundError"
export type IntegrationAttemptNotFoundError = {
readonly _tag: "IntegrationAttemptNotFoundError"
readonly integrationID: string
readonly attemptID: string
readonly message: string
}
export const isIntegrationAttemptNotFoundError = (value: unknown): value is IntegrationAttemptNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "IntegrationAttemptNotFoundError"
export type IntegrationMethodNotFoundError = {
readonly _tag: "IntegrationMethodNotFoundError"
readonly integrationID: string
readonly methodID: string
readonly message: string
}
export const isIntegrationMethodNotFoundError = (value: unknown): value is IntegrationMethodNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "IntegrationMethodNotFoundError"
export type McpServerNotFoundError = {
readonly _tag: "McpServerNotFoundError"
readonly server: string
@@ -3832,9 +3854,7 @@ export type SessionRemoveOutput = void
export type SessionForkInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly boundary: {
readonly boundary: { readonly type: "before"; readonly messageID: string } | { readonly type: "through" }
}["boundary"]
readonly before?: { readonly before?: string | undefined }["before"]
}
export type SessionForkOutput = { data: SessionInfo }["data"]
@@ -4639,6 +4659,7 @@ export type McpAddInput = {
readonly disabled?: boolean
readonly codemode?: boolean
readonly timeout?: { readonly startup?: number; readonly catalog?: number; readonly execution?: number }
readonly protocol?: "legacy" | "auto" | "2026-07-28"
}
| {
readonly type: "remote"
@@ -4656,6 +4677,7 @@ export type McpAddInput = {
readonly disabled?: boolean
readonly codemode?: boolean
readonly timeout?: { readonly startup?: number; readonly catalog?: number; readonly execution?: number }
readonly protocol?: "legacy" | "auto" | "2026-07-28"
}
}["config"]
}
@@ -4691,23 +4713,16 @@ export type McpResourceCatalogOutput = { location: LocationPublicRef; data: McpR
export type CredentialUpdateInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly label: { readonly label: string }["label"]
}
export type CredentialUpdateOutput = void
export type CredentialActivateInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}
export type CredentialActivateInput = { readonly credentialID: { readonly credentialID: string }["credentialID"] }
export type CredentialActivateOutput = void
export type CredentialRemoveInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}
export type CredentialRemoveInput = { readonly credentialID: { readonly credentialID: string }["credentialID"] }
export type CredentialRemoveOutput = void
+2 -4
View File
@@ -239,12 +239,10 @@ test("credential.activate uses the public HTTP contract", async () => {
},
})
await client.credential.activate({ credentialID: "cred_work", location: { directory: "/tmp/project" } })
await client.credential.activate({ credentialID: "cred_work" })
expect(request?.method).toBe("POST")
expect(request?.url).toBe(
"http://localhost:3000/api/credential/cred_work/activate?location%5Bdirectory%5D=%2Ftmp%2Fproject",
)
expect(request?.url).toBe("http://localhost:3000/api/credential/cred_work/activate")
})
test("integration connections optionally submit a form answer", async () => {
+39 -1
View File
@@ -20,7 +20,9 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced.
- [x] Program results use JSON-like boundaries, with `undefined` and non-finite numbers normalized to `null`. Tool
arguments follow JSON serialization semantics before their schema applies (see the tools section).
arguments follow JSON serialization semantics before their schema applies (see the tools section). Own
`__proto__` keys are dropped wherever a host object crosses to the host, so merging tool inputs or results
cannot replace a prototype; `JSON.stringify` still emits the key, like JS, since a string cannot pollute.
- [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside CodeMode.
- [x] Tool calls through the host-provided `tools` tree only.
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
@@ -100,6 +102,10 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Function declarations, function expressions, and arrow functions.
- [x] Synchronous and `async` functions.
- [x] Closures, recursion, default parameters, rest parameters, and destructured parameters.
- [x] A call depth limit of 10000: deeper nesting throws a catchable `RangeError: Maximum call stack size exceeded`
at the overflowing call instead of running until the timeout. Callbacks invoked by built-ins count below the
call that invoked the built-in, and a resumed `await` starts from depth 0 as in JS, so long async chains such
as recursive pagination are unaffected.
- [x] Expression and block function bodies.
- [x] User callbacks for the supported Array, Map, Set, URLSearchParams, sort, string-replacement, and `Array.from`
mapper APIs, with one shared acceptance rule everywhere including promise reactions.
@@ -417,6 +423,29 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [ ] `crypto.getRandomValues` and `crypto.subtle`, `TextEncoder`/`TextDecoder`, and `Blob`: these need a binary
value type, which the JSON-like data model does not have yet.
## Extensions
Host classes and functions a host opts in through `Extension.make({ name, globals })` and `CodeMode.make({ extensions })`.
Nothing is exposed unless a host provides it; extension calls are not tool calls.
- [x] Each global is a class or a function, exposed as-is: constructors with `new`, prototype methods, accessors,
data properties, and statics (including through an exposed subclass, so `new this()` works), plus inheritance
up to the nearest exposed ancestor. A global that shadows a built-in or another extension throws at `make`.
- [x] Instances of exposed classes stay on the host; the program holds a handle whose only members are the class's.
The same host instance is always the same handle within a run, so identity and `instanceof` hold. Handles
cannot cross the data boundary: returning, stringifying, throwing, or passing one to a tool fails.
- [x] Every value crossing in either direction is converted, never shared: plain objects and arrays are copied,
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Map`, and `Set` become fresh copies with their contents converted,
errors cross as errors with their name and message, and a `__proto__` key is dropped. Functions, generators,
and un-awaited promises cannot be passed in; an instance of an unexposed class cannot come out.
- [x] A host `Promise` becomes a program promise; its settlement converts like a return and a rejection is caught
like any error. A getter must be synchronous.
- [x] A prototype member runs only with a handle of its own class as `this`; a detached call, a plain object, or a
handle of another class throws `TypeError: Illegal invocation`. Program edits to an exposed prototype affect
that run only.
- [ ] Program functions as arguments to extension code (callbacks such as `forEach`).
- [ ] Binary values (`Uint8Array`, `ArrayBuffer`) at the extension boundary; needs the binary value type above.
## Errors and diagnostics
- [x] `Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, and `URIError`, callable with
@@ -440,3 +469,12 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Failures raised by the interpreter are `TypeError`s unless JavaScript names them otherwise (`RangeError`,
`ReferenceError`, `SyntaxError`, `URIError`), so `e instanceof TypeError` and `e.constructor === TypeError`
hold. Unsupported syntax reached at runtime is a `SyntaxError`; awaited tool failures stay plain `Error`.
Host errors escaping a built-in (`(1).toFixed(200)`) become the same-named program error at the call.
A failure raised inside a promise a built-in created (`Promise.all(1)`, `Promise.race([])`, a resolution cycle)
is located at the call that created the promise.
- [x] One failure is one error object: every `catch`, rejection handler, and `allSettled` reason for the same
failure sees the identical value, so `a === b` holds after awaiting the same rejected promise twice.
- [x] Rethrowing an interpreter failure keeps its diagnostic: `catch (e) { throw e }` still reports the original
kind and source location. Uncaught errors report as `name: message` whoever raised them, as
`Error.prototype.toString` would (`TypeError: Cannot read properties of null (reading 'foo').`,
`TypeError: bad input`); other thrown values report as `Uncaught: <value>`.
+14 -1
View File
@@ -1,5 +1,8 @@
import { Effect, Schema } from "effect"
import type { Extension } from "./extension.js"
import { executeProgram } from "./interpreter/execute.js"
import { extensionGlobals } from "./interpreter/extensions.js"
import { globalNames } from "./interpreter/globals.js"
import { type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js"
import type { Tools } from "./tools.js"
@@ -34,6 +37,8 @@ export type ResolvedExecutionLimits = {
export type Options<Provided extends Record<string, unknown> = {}> = ToolRuntime.ToolCallHooks<Services<Provided>> & {
/** Explicit tools exposed to the program as `tools`. */
tools?: Provided & Tools<Services<Provided>>
/** Host classes and functions exposed as globals; see `Extension.make`. */
extensions?: ReadonlyArray<Extension>
/** Resource limits enforced on each execution. */
limits?: ExecutionLimits
}
@@ -133,8 +138,16 @@ export const make = <const Provided extends Record<string, unknown> = {}>(
): Runtime<Services<Provided>> => {
const prepared = ToolRuntime.prepare((options.tools ?? {}) as Tools<Services<Provided>>)
const limits = resolveExecutionLimits(options.limits)
const extensions = options.extensions ?? []
const bound = new Set(globalNames)
for (const extension of extensions) {
for (const name of Object.keys(extension.globals)) {
if (bound.has(name)) throw new TypeError(`Extension "${extension.name}" global "${name}" is already defined.`)
bound.add(name)
}
}
return {
catalog: prepared.catalog,
execute: (code) => executeProgram(code, prepared, limits, options),
execute: (code) => executeProgram(code, prepared, limits, options, (host) => extensionGlobals(host, extensions)),
}
}
+22 -4
View File
@@ -13,6 +13,7 @@ import {
ProgramDate,
ProgramError,
ProgramGenerator,
ProgramHandle,
ProgramMap,
ProgramObject,
ProgramPromise,
@@ -22,7 +23,7 @@ import {
ProgramURLSearchParams,
} from "./interpreter/objects.js"
const MAX_VALUE_DEPTH = 32
export const MAX_VALUE_DEPTH = 32
export class ToolRuntimeError extends Error {
constructor(
@@ -59,8 +60,12 @@ export const fromData = (protos: Prototypes, value: unknown, label: string): unk
* dropped ("json") or become null ("result", for program results where the consumer must never see
* undefined); a bare `undefined` follows the same rule.
*/
export const toData = (value: unknown, label: string, undefinedAs: "json" | "result" = "json"): unknown =>
copy(value, label, undefinedAs, 0, new Set())
export const toData = (
value: unknown,
label: string,
undefinedAs: "json" | "result" = "json",
stripProto = true,
): unknown => copy(value, label, undefinedAs, 0, new Set(), undefined, stripProto)
// "program" and "data" build program objects; "json" and "result" build ordinary objects for the host.
type Mode = "program" | "data" | "json" | "result"
@@ -72,8 +77,9 @@ const copy = (
depth: number,
seen: Set<object>,
protos?: Prototypes,
stripProto = true,
): unknown => {
const next = (item: unknown) => copy(item, label, mode, depth + 1, seen, protos)
const next = (item: unknown) => copy(item, label, mode, depth + 1, seen, protos, stripProto)
if (depth > MAX_VALUE_DEPTH) {
throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
}
@@ -92,6 +98,16 @@ const copy = (
if ((value instanceof Callable || value instanceof ProgramGenerator) && mode !== "program") {
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`)
}
if (value instanceof ProgramHandle && mode !== "program") {
throw new ToolRuntimeError(
"InvalidDataValue",
`${label} contains a ${value.instance.constructor.name}, which only extension functions accept.`,
)
}
// Host-produced input never holds program objects; one arriving here would come back as a host object.
if (value instanceof ProgramObject && mode === "data") {
throw new ToolRuntimeError("InvalidDataValue", `${label} must be host data, not a program value.`)
}
if (protos !== undefined && mode === "program") {
if (value instanceof ProgramObject) return value
@@ -145,6 +161,7 @@ const copy = (
defineHost(copied, "message", next(get(value, "message")))
}
for (const [key, item] of entries(value)) {
if (stripProto && key === "__proto__") continue
const copiedItem = next(item)
if (copiedItem === undefined && mode === "json") continue
defineHost(copied, key, copiedItem)
@@ -180,6 +197,7 @@ const copy = (
}
const copied: Record<string, unknown> = {}
for (const [key, item] of Object.entries(value)) {
if (stripProto && key === "__proto__") continue
const copiedItem = next(item)
if (copiedItem === undefined && mode === "json") continue
defineHost(copied, key, copiedItem)
+21
View File
@@ -0,0 +1,21 @@
export * as Extension from "./extension.js"
/**
* Host classes and functions a program uses directly, like JavaScript. Values crossing in either direction are
* converted, never shared: plain data is copied, instances of the classes stay on the host behind program-side
* handles. Extension calls are not tool calls.
*/
export type Extension = {
readonly name: string
/** Each value is a class or a function; everything on a class is exposed, including statics and accessors. */
readonly globals: Readonly<Record<string, Function>>
}
export const make = (options: Extension): Extension => {
for (const [name, value] of Object.entries(options.globals)) {
if (typeof value !== "function") {
throw new TypeError(`Extension "${options.name}" global "${name}" must be a class or a function.`)
}
}
return { name: options.name, globals: { ...options.globals } }
}
+1
View File
@@ -1,4 +1,5 @@
export * as CodeMode from "./codemode.js"
export * as Extension from "./extension.js"
export * as Namespace from "./namespace.js"
export * as Tool from "./tool.js"
export * as OpenAPI from "./openapi/index.js"
+47 -29
View File
@@ -2,7 +2,7 @@ import { Effect } from "effect"
import type { Diagnostic } from "../codemode.js"
import { ToolError } from "../tool-error.js"
import { toData, ToolRuntimeError } from "../data.js"
import { type AstNode, formatLocation, InterpreterRuntimeError, ProgramThrow, sourceLocation } from "./model.js"
import { type AstNode, formatLocation, PendingThrow, ProgramThrow, sourceLocation, typeError } from "./model.js"
import { containsRuntimeReference } from "./references.js"
import { createErrorValue, type ErrorType, isErrorType } from "./intrinsics.js"
import { constructor, methods, prototypeFrom, receiver } from "./native.js"
@@ -20,10 +20,10 @@ import { type Runner } from "./runner.js"
import { coerceToString } from "../stdlib/value.js"
export const normalizeError = (error: unknown): Diagnostic => {
if (error instanceof InterpreterRuntimeError) {
if (error instanceof PendingThrow) {
return {
kind: error.kind,
message: `${error.message}${formatLocation(error.node)}`,
message: `${error.type}: ${error.message}${formatLocation(error.node)}`,
...(error.node?.loc ? { location: sourceLocation(error.node) } : {}),
...(error.suggestions ? { suggestions: error.suggestions } : {}),
}
@@ -43,14 +43,15 @@ export const normalizeError = (error: unknown): Diagnostic => {
if (error instanceof ProgramThrow) {
const value = error.value
if (value instanceof ProgramError) {
return value.host ? normalizeError(value.host) : { kind: "ExecutionFailure", message: errorToString(value) }
}
let message: string
if (containsRuntimeReference(value)) {
// Never expose runtime reference internals through thrown values.
message = "a non-data value"
} else if (typeof value === "string") {
message = value
} else if (value instanceof ProgramObject && typeof get(value, "message") === "string") {
message = get(value, "message") as string
} else {
try {
message = JSON.stringify(toData(value, "Thrown value")) ?? String(value)
@@ -81,14 +82,47 @@ export const normalizeError = (error: unknown): Diagnostic => {
}
}
export const caughtErrorValue = <R>(runner: Runner<R>, thrown: unknown): unknown => {
/**
* Gives a failure the source location of the expression that raised it, keeping the first one attached. Host errors
* that escape a built-in become the equivalent program error here.
*/
export const locate = (error: unknown, node?: AstNode): unknown => {
if (error instanceof PendingThrow) {
if (error.node === undefined && node) error.node = node
return error
}
if (error instanceof Error && !(error instanceof ToolError) && !(error instanceof ToolRuntimeError)) {
return new PendingThrow(isErrorType(error.name) ? error.name : "Error", error.message, node)
}
return error
}
/** The program value a handler receives for a failure; one failure always yields the same value. */
export const materialize = <R>(runner: Runner<R>, thrown: unknown): unknown => {
if (thrown instanceof ProgramThrow) return thrown.value
const prototypes = runner.prototypes
if (thrown instanceof InterpreterRuntimeError) return createErrorValue(prototypes[thrown.type], thrown.message)
if (thrown instanceof PendingThrow) {
if (thrown.value === undefined) {
thrown.value = createErrorValue(prototypes[thrown.type], thrown.message)
thrown.value.host = thrown
}
return thrown.value
}
const type = thrown instanceof Error && isErrorType(thrown.name) ? thrown.name : "Error"
return createErrorValue(prototypes[type], normalizeError(thrown).message)
}
/** Error.prototype.toString: `name: message`, omitting whichever side is empty. */
const errorToString = (self: ProgramObject): string => {
const name = get(self, "name")
const message = get(self, "message")
const shownName = name === undefined ? "Error" : coerceToString(name)
const shownMessage = message === undefined ? "" : coerceToString(message)
if (shownMessage === "") return shownName
if (shownName === "") return shownMessage
return `${shownName}: ${shownMessage}`
}
export const createAggregateErrorValue = <R>(
runner: Runner<R>,
errors: Array<unknown>,
@@ -104,13 +138,10 @@ const constructAggregateErrorValue = <R>(
runner: Runner<R>,
args: Array<unknown>,
proto: ProgramObject,
node: AstNode,
): Effect.Effect<ProgramError, unknown, R> =>
Effect.gen(function* () {
const cursor = yield* runner.syncIterator(args[0], node)
if (cursor === undefined) {
throw new InterpreterRuntimeError("new AggregateError(...) expects a synchronous iterable of errors.", node)
}
const cursor = yield* runner.syncIterator(args[0])
if (cursor === undefined) throw typeError("new AggregateError(...) expects a synchronous iterable of errors.")
const errors: Array<unknown> = []
while (true) {
const step = yield* cursor.next
@@ -125,34 +156,21 @@ const constructAggregateErrorValue = <R>(
export const errorGlobal = <R>(type: ErrorType, runner: Runner<R>) => {
const protos = runner.prototypes
const prototype = protos[type]
const construct = (args: Array<unknown>, newTarget: Callable, node: AstNode) => {
const construct = (args: Array<unknown>, newTarget: Callable) => {
const proto = prototypeFrom(newTarget, prototype)
return type === "AggregateError"
? constructAggregateErrorValue(runner, args, proto, node)
? constructAggregateErrorValue(runner, args, proto)
: Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0])))
}
const ctor: NativeFunction<R> = constructor<R>(protos, prototype, {
name: type,
length: type === "AggregateError" ? 2 : 1,
call: (_, args, node) => construct(args, ctor, node),
call: (_, args) => construct(args, ctor),
construct,
})
if (type === "Error") {
methods(protos, prototype, [
[
"toString",
0,
(thisValue, _, node) => {
const self = receiver(ProgramObject, thisValue, "Error.prototype.toString", node)
const name = get(self, "name")
const message = get(self, "message")
const shownName = name === undefined ? "Error" : coerceToString(name)
const shownMessage = message === undefined ? "" : coerceToString(message)
if (shownMessage === "") return shownName
if (shownName === "") return shownMessage
return `${shownName}: ${shownMessage}`
},
],
["toString", 0, (thisValue) => errorToString(receiver(ProgramObject, thisValue, "Error.prototype.toString"))],
])
}
return ctor
+2 -2
View File
@@ -9,7 +9,7 @@ import { ToolRuntime } from "../tool-runtime.js"
import { normalizeError } from "./errors.js"
import { createPrototypes } from "./intrinsics.js"
import type { Host } from "./globals.js"
import { InterpreterRuntimeError } from "./model.js"
import { PendingThrow } from "./model.js"
import { PromiseRuntime } from "./promises.js"
import { Runtime } from "./runtime.js"
@@ -122,7 +122,7 @@ const parseProgram = (code: string): Program => {
const transpiled = transpile(`async function __codemode__() {\n${code}\n}`)
if (transpiled.error !== undefined) {
throw new InterpreterRuntimeError(`Failed to parse TypeScript: ${transpiled.error}`, undefined, "ParseError")
throw new PendingThrow("SyntaxError", `Failed to parse TypeScript: ${transpiled.error}`, undefined, "ParseError")
}
const bodyStart = transpiled.outputText.indexOf("{") + 1
@@ -0,0 +1,288 @@
import { Effect } from "effect"
import { MAX_VALUE_DEPTH } from "../data.js"
import type { Extension } from "../extension.js"
import { coerceToString } from "../stdlib/value.js"
import type { Host } from "./globals.js"
import { createErrorValue, isErrorType } from "./intrinsics.js"
import { typeError } from "./model.js"
import { constructor, fn } from "./native.js"
import {
Callable,
define,
defineAccessor,
entries,
get,
hidden,
type NativeFunction,
ProgramArray,
ProgramDate,
ProgramError,
ProgramGenerator,
ProgramHandle,
ProgramMap,
ProgramObject,
ProgramPromise,
ProgramRegExp,
ProgramSet,
ProgramURL,
ProgramURLSearchParams,
} from "./objects.js"
import { describeValue } from "./references.js"
type Class = Function & { readonly prototype: object }
const isClass = (value: unknown): value is Class =>
typeof value === "function" && typeof value.prototype === "object" && value.prototype !== null
// Own keys the native function already carries.
const ownFunctionKeys = new Set(["length", "name", "prototype"])
const ownPrototypeKeys = new Set(["constructor"])
/**
* The global bindings of one run's extensions. Everything crossing the boundary is converted: plain data is
* copied, built-in wrappers are copied, instances of exposed classes travel as handles, and a host Promise becomes
* a program promise. Prototypes, constructors, and handle identity are all per run.
*/
export const extensionGlobals = <R>(
host: Host<R>,
extensions: ReadonlyArray<Extension>,
): ReadonlyArray<readonly [string, unknown]> => {
const protos = host.runner.prototypes
const classes = new Set(extensions.flatMap((extension) => Object.values(extension.globals)).filter(isClass))
// Host prototype object → this run's program prototype, so an instance wraps as its most-derived exposed class.
const prototypes = new Map<object, ProgramObject>()
const exposed = new Map<Class, { ctor: NativeFunction<R>; proto: ProgramObject }>()
const classOf = new Map<unknown, Class>()
const handles = new WeakMap<object, ProgramHandle>()
const toHost = (value: unknown, label: string, depth = 0, seen = new Set<object>()): unknown => {
if (depth > MAX_VALUE_DEPTH) throw typeError(`${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
if (value === null || typeof value !== "object") return value
if (value instanceof ProgramHandle) return value.instance
if (value instanceof ProgramDate) return new Date(value.time)
if (value instanceof ProgramRegExp) return new RegExp(value.regex.source, value.regex.flags)
if (value instanceof ProgramURL) return new URL(value.url.href)
if (value instanceof ProgramURLSearchParams) return new URLSearchParams(value.params)
const next = (item: unknown) => toHost(item, label, depth + 1, seen)
if (value instanceof ProgramMap) return new Map([...value.map].map(([key, item]) => [next(key), next(item)]))
if (value instanceof ProgramSet) return new Set([...value.set].map(next))
if (
!(value instanceof ProgramObject) ||
value instanceof Callable ||
value instanceof ProgramGenerator ||
value instanceof ProgramPromise
) {
throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`)
}
if (value instanceof ProgramError) {
const message = get(value, "message")
const error = new Error(message === undefined ? "" : coerceToString(message))
error.name = coerceToString(get(value, "name"))
return error
}
if (seen.has(value)) throw typeError(`${label} contains a circular value.`)
seen.add(value)
const copied =
value instanceof ProgramArray
? value.items.map(next)
: Object.fromEntries(
entries(value)
.filter(([key]) => key !== "__proto__")
.map(([key, item]) => [key, next(item)]),
)
seen.delete(value)
return copied
}
const fromHost = (value: unknown, label: string, depth = 0, seen = new Set<object>()): unknown => {
if (depth > MAX_VALUE_DEPTH) throw typeError(`${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
if (value === null || (typeof value !== "object" && typeof value !== "function")) return value
if (typeof value === "object") {
const existing = handles.get(value)
if (existing !== undefined) return existing
const proto = handlePrototype(value)
if (proto !== undefined) {
const handle = new ProgramHandle(proto, value)
handles.set(value, handle)
return handle
}
if (value instanceof Date) return new ProgramDate(protos.Date, value.getTime())
if (value instanceof RegExp) return new ProgramRegExp(protos.RegExp, value.source, value.flags)
if (value instanceof Error) {
return createErrorValue(protos[isErrorType(value.name) ? value.name : "Error"], value.message)
}
if (value instanceof URL) return new ProgramURL(protos.URL, protos.URLSearchParams, new URL(value.href))
if (value instanceof URLSearchParams) {
return new ProgramURLSearchParams(protos.URLSearchParams, new URLSearchParams(value))
}
const next = (item: unknown) => fromHost(item, label, depth + 1, seen)
if (value instanceof Map) {
const wrapped = new ProgramMap(protos.Map)
for (const [key, item] of value) wrapped.map.set(next(key), next(item))
return wrapped
}
if (value instanceof Set) {
const wrapped = new ProgramSet(protos.Set)
for (const item of value) wrapped.set.add(next(item))
return wrapped
}
if (seen.has(value)) throw typeError(`${label} returned a circular value.`)
seen.add(value)
if (Array.isArray(value)) {
const copied = new ProgramArray(protos.Array, value.map(next))
seen.delete(value)
return copied
}
const prototype = Object.getPrototypeOf(value)
if (prototype === Object.prototype || prototype === null) {
const copied = new ProgramObject(protos.Object)
for (const [key, item] of Object.entries(value)) define(copied, key, next(item))
seen.delete(value)
return copied
}
}
throw typeError(`${label} returned ${describeHost(value)}, which the program cannot hold.`)
}
const handlePrototype = (instance: object): ProgramObject | undefined => {
for (let level = Object.getPrototypeOf(instance); level !== null; level = Object.getPrototypeOf(level)) {
const proto = prototypes.get(level)
if (proto !== undefined) return proto
}
return undefined
}
// A settled Promise converts like any other return; a rejection reaches the program through `locate`.
const out = (value: unknown, label: string): unknown =>
value instanceof Promise
? host.promises.create(
Effect.map(
Effect.promise(() => value),
(settled) => fromHost(settled, label),
),
)
: fromHost(value, label)
const args = (values: Array<unknown>, label: string): Array<unknown> =>
values.map((value, index) => toHost(value, `Argument ${index + 1} to ${label}`))
// Own members of each level from `from` up to (excluding) `root`, child first, as JS resolves them.
const members = (
target: ProgramObject,
from: object,
root: object,
skip: ReadonlySet<string>,
label: string,
receiver: (thisValue: unknown, member: string) => unknown,
): void => {
for (let level: object | null = from; level !== null && level !== root; level = Object.getPrototypeOf(level)) {
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(level))) {
if (skip.has(key) || target.props.has(key)) continue
const name = `${label}.${key}`
if (typeof descriptor.value === "function") {
const method: Function = descriptor.value
const impl = (thisValue: unknown, values: Array<unknown>) =>
out(method.apply(receiver(thisValue, name), args(values, name)), name)
define(target, key, fn<R>(protos, key, method.length, impl), hidden)
continue
}
// A data property reads and writes live, like JS, with each value converted.
if ("value" in descriptor) {
const source = level as Record<string, unknown>
defineAccessor(
target,
key,
() => fromHost(source[key], name),
descriptor.writable
? (_, value) => {
source[key] = toHost(value, `${name} value`)
}
: undefined,
)
continue
}
const get = descriptor.get
const set = descriptor.set
defineAccessor(
target,
key,
get === undefined
? undefined
: (thisValue) => {
const value = get.call(receiver(thisValue, name))
if (value instanceof Promise)
throw typeError(`${name} returned a Promise; a getter must be synchronous.`)
return fromHost(value, name)
},
set === undefined
? undefined
: (thisValue, value) => {
set.call(receiver(thisValue, name), toHost(value, `${name} value`))
},
)
}
}
}
const expose = (cls: Class): { ctor: NativeFunction<R>; proto: ProgramObject } => {
const existing = exposed.get(cls)
if (existing !== undefined) return existing
const ancestor = exposedAncestor(cls)
const base = ancestor === undefined ? undefined : expose(ancestor)
const proto = new ProgramObject(base === undefined ? protos.Object : base.proto)
prototypes.set(cls.prototype, proto)
const name = cls.name
const ctor = constructor<R>(protos, proto, {
name,
length: cls.length,
call: (_, values) => Effect.sync(() => out(cls.apply(undefined, args(values, name)), name)),
construct: (values) => {
const label = `new ${name}`
const construct = cls as new (...values: Array<unknown>) => object
return Effect.sync(() => fromHost(new construct(...args(values, label)), label))
},
})
if (base !== undefined) ctor.proto = base.ctor
const entry = { ctor, proto }
exposed.set(cls, entry)
classOf.set(ctor, cls)
// A static called through an exposed subclass sees that subclass as `this`, like JS.
members(ctor, cls, ancestor ?? Function.prototype, ownFunctionKeys, name, (thisValue) => {
const called = classOf.get(thisValue)
return called !== undefined && (called === cls || called.prototype instanceof cls) ? called : cls
})
members(
proto,
cls.prototype,
ancestor?.prototype ?? Object.prototype,
ownPrototypeKeys,
`${name}.prototype`,
(thisValue, member) => {
if (thisValue instanceof ProgramHandle && thisValue.instance instanceof cls) return thisValue.instance
throw typeError(`Illegal invocation: ${member} called on ${describeValue(thisValue)}.`)
},
)
return entry
}
const exposedAncestor = (cls: Class): Class | undefined => {
for (let level = Object.getPrototypeOf(cls); isClass(level); level = Object.getPrototypeOf(level)) {
if (classes.has(level)) return level
}
return undefined
}
return extensions.flatMap((extension) =>
Object.entries(extension.globals).map(([name, value]) => {
if (isClass(value)) return [name, expose(value).ctor] as const
const impl = (_: unknown, values: Array<unknown>) => out(value.apply(undefined, args(values, name)), name)
return [name, fn<R>(protos, name, value.length, impl)] as const
}),
)
}
const describeHost = (value: unknown): string => {
if (typeof value === "function") return "a function"
const name = (value as { constructor?: { name?: string } }).constructor?.name
return name === undefined || name === "" ? "an object" : `a ${name}`
}
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { fn, type Method, methods, receiver } from "./native.js"
import { type AstNode, AsyncIteratorSymbol, type GeneratorRequestKind, IteratorSymbol } from "./model.js"
import { AsyncIteratorSymbol, type GeneratorRequestKind, IteratorSymbol } from "./model.js"
import { define, hidden, ProgramGenerator } from "./objects.js"
import type { PromiseRuntime } from "./promises.js"
import type { Runner } from "./runner.js"
@@ -14,9 +14,9 @@ export const generatorGlobals = <R>(runner: Runner<R>, promises: PromiseRuntime<
const request = (kind: GeneratorRequestKind): Method => [
kind,
1,
(thisValue: unknown, args: Array<unknown>, node: AstNode) => {
const generator = receiver(ProgramGenerator, thisValue, `${label}.prototype.${kind}`, node)
const requested = generator.request(kind, args[0], node) as Effect.Effect<unknown, unknown, R>
(thisValue: unknown, args: Array<unknown>) => {
const generator = receiver(ProgramGenerator, thisValue, `${label}.prototype.${kind}`)
const requested = generator.request(kind, args[0]) as Effect.Effect<unknown, unknown, R>
return generator.asynchronous ? promises.create(requested) : requested
},
]
+52 -47
View File
@@ -16,7 +16,7 @@ import { ToolReference } from "../tool-runtime.js"
import { errorGlobal } from "./errors.js"
import { errorTypes } from "./intrinsics.js"
import { constants, constructor, native } from "./native.js"
import { type AstNode, AsyncIteratorSymbol, InterpreterRuntimeError, IteratorSymbol } from "./model.js"
import { AsyncIteratorSymbol, IteratorSymbol, typeError } from "./model.js"
import { generatorGlobals } from "./generators.js"
import { promiseGlobal, type PromiseRuntime } from "./promises.js"
import type { Runner } from "./runner.js"
@@ -32,27 +32,24 @@ export type Host<R> = {
// Function.prototype.constructor exists so `fn.constructor === Function` holds; dynamic code is unsupported.
const functionGlobal = <R>(runner: Runner<R>) => {
const reject = (_: unknown, __: Array<unknown>, node: AstNode) =>
const reject = () =>
Effect.sync(() => {
throw new InterpreterRuntimeError("The Function constructor is not supported; write the function inline.", node)
throw typeError("The Function constructor is not supported; write the function inline.")
})
return constructor<R>(runner.prototypes, runner.prototypes.Function, {
name: "Function",
length: 1,
call: reject,
construct: (args, _, node) => reject(undefined, args, node),
construct: reject,
})
}
const symbolGlobal = <R>(runner: Runner<R>) => {
const symbol = native<R>(runner.prototypes, {
name: "Symbol",
call: (_, __, node) =>
call: () =>
Effect.sync(() => {
throw new InterpreterRuntimeError(
"Symbol is not callable; only Symbol.asyncIterator and Symbol.iterator are available.",
node,
)
throw typeError("Symbol is not callable; only Symbol.asyncIterator and Symbol.iterator are available.")
}),
callback: false,
})
@@ -60,44 +57,52 @@ const symbolGlobal = <R>(runner: Runner<R>) => {
return symbol
}
type Factory = <R>(host: Host<R>) => unknown
// A table rather than a list so the names are known before any runtime exists.
const table: Record<string, Factory> = {
tools: () => new ToolReference([]),
search: (host) =>
native(host.runner.prototypes, { name: "search", call: (_, args) => host.search(args), callback: false }),
undefined: () => undefined,
NaN: () => NaN,
Infinity: () => Infinity,
Object: (host) => objectGlobal(host.runner, host.toolKeys),
Function: (host) => functionGlobal(host.runner),
Array: (host) => arrayGlobal(host.runner),
Math: (host) => mathGlobal(host.runner),
JSON: (host) => jsonGlobal(host.runner),
console: (host) => consoleGlobal(host.runner, host.logs),
Promise: (host) => promiseGlobal(host.runner, host.promises),
Symbol: (host) => symbolGlobal(host.runner),
Number: (host) => numberGlobal(host.runner),
String: (host) => stringGlobal(host.runner),
Boolean: (host) => booleanGlobal(host.runner),
parseInt: (host) => coercion(host.runner, "parseInt", 2),
parseFloat: (host) => coercion(host.runner, "parseFloat"),
isFinite: (host) => coercion(host.runner, "isFinite"),
isNaN: (host) => coercion(host.runner, "isNaN"),
Date: (host) => dateGlobal(host.runner),
RegExp: (host) => regexpGlobal(host.runner),
Map: (host) => mapGlobal(host.runner),
Set: (host) => setGlobal(host.runner),
URL: (host) => urlGlobal(host.runner),
URLSearchParams: (host) => urlSearchParamsGlobal(host.runner),
encodeURI: (host) => uriGlobal(host.runner, "encodeURI"),
encodeURIComponent: (host) => uriGlobal(host.runner, "encodeURIComponent"),
decodeURI: (host) => uriGlobal(host.runner, "decodeURI"),
decodeURIComponent: (host) => uriGlobal(host.runner, "decodeURIComponent"),
atob: (host) => base64Global(host.runner, "atob"),
btoa: (host) => base64Global(host.runner, "btoa"),
crypto: (host) => cryptoGlobal(host.runner),
...Object.fromEntries(errorTypes.map((type) => [type, <R>(host: Host<R>) => errorGlobal(type, host.runner)])),
}
/** Names bound in every program before extensions apply. */
export const globalNames: ReadonlySet<string> = new Set(Object.keys(table))
/** The immutable global bindings of every program, in declaration order. */
export const globals = <R>(host: Host<R>): ReadonlyArray<readonly [string, unknown]> => {
const runner = host.runner
generatorGlobals(runner, host.promises)
return [
["tools", new ToolReference([])],
["search", native<R>(runner.prototypes, { name: "search", call: (_, args) => host.search(args), callback: false })],
["undefined", undefined],
["NaN", NaN],
["Infinity", Infinity],
["Object", objectGlobal(runner, host.toolKeys)],
["Function", functionGlobal(runner)],
["Array", arrayGlobal(runner)],
["Math", mathGlobal(runner)],
["JSON", jsonGlobal(runner)],
["console", consoleGlobal(runner, host.logs)],
["Promise", promiseGlobal(runner, host.promises)],
["Symbol", symbolGlobal(runner)],
["Number", numberGlobal(runner)],
["String", stringGlobal(runner)],
["Boolean", booleanGlobal(runner)],
["parseInt", coercion(runner, "parseInt", 2)],
["parseFloat", coercion(runner, "parseFloat")],
["isFinite", coercion(runner, "isFinite")],
["isNaN", coercion(runner, "isNaN")],
["Date", dateGlobal(runner)],
["RegExp", regexpGlobal(runner)],
["Map", mapGlobal(runner)],
["Set", setGlobal(runner)],
["URL", urlGlobal(runner)],
["URLSearchParams", urlSearchParamsGlobal(runner)],
["encodeURI", uriGlobal(runner, "encodeURI")],
["encodeURIComponent", uriGlobal(runner, "encodeURIComponent")],
["decodeURI", uriGlobal(runner, "decodeURI")],
["decodeURIComponent", uriGlobal(runner, "decodeURIComponent")],
["atob", base64Global(runner, "atob")],
["btoa", base64Global(runner, "btoa")],
["crypto", cryptoGlobal(runner)],
...errorTypes.map((type) => [type, errorGlobal(type, runner)] as const),
]
generatorGlobals(host.runner, host.promises)
return Object.entries(table).map(([name, factory]) => [name, factory(host)] as const)
}
+24 -18
View File
@@ -1,10 +1,17 @@
import type { Node } from "acorn"
import { Context } from "effect"
import type { ErrorType } from "./intrinsics.js"
import type { DiagnosticKind } from "../codemode.js"
import type { ProgramError } from "./objects.js"
/** Any parsed node; the interpreter narrows on `type` and reads `loc` for diagnostics. */
export type AstNode = Node
/** The program call a built-in is running under: where to locate failures born inside it, and how deep the stack is there. */
export const CallSite = Context.Reference<{ readonly node?: AstNode; readonly depth: number }>("codemode/CallSite", {
defaultValue: () => ({ depth: 0 }),
})
export type Binding = {
mutable: boolean
value: unknown
@@ -33,32 +40,31 @@ export class GeneratorReturn {
export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
export class InterpreterRuntimeError extends Error {
readonly node?: AstNode
/**
* A failure raised by the interpreter or a built-in. It travels as a defect and becomes one program Error object
* the first time a handler observes it, so every observer of the same failure sees the same value.
*/
export class PendingThrow {
node?: AstNode
value?: ProgramError
constructor(
message: string,
/** The JS error class a program sees when it catches this failure. */
readonly type: ErrorType,
readonly message: string,
node?: AstNode,
readonly kind: DiagnosticKind = "ExecutionFailure",
readonly suggestions?: ReadonlyArray<string>,
/** The JS error class a program sees when it catches this failure. */
readonly type: ErrorType = "TypeError",
) {
super(message)
this.name = "InterpreterRuntimeError"
if (node) this.node = node
}
}
/** Attaches a source location to a failure raised where none was known, such as inside a property accessor. */
export const locate = (error: unknown, node: AstNode): unknown =>
error instanceof InterpreterRuntimeError && error.node === undefined
? new InterpreterRuntimeError(error.message, node, error.kind, error.suggestions, error.type)
: error
const failure = (type: ErrorType) => (message: string, node?: AstNode) =>
new InterpreterRuntimeError(message, node, "ExecutionFailure", undefined, type)
const failure = (type: ErrorType, kind?: DiagnosticKind) => (message: string, node?: AstNode) =>
new PendingThrow(type, message, node, kind)
export const typeError = failure("TypeError")
export const invalidData = failure("TypeError", "InvalidDataValue")
export const rangeError = failure("RangeError")
export const referenceError = failure("ReferenceError")
export const syntaxError = failure("SyntaxError")
@@ -68,13 +74,13 @@ export const uriError = failure("URIError")
export const supportedSyntaxMessage =
"This is a restricted JavaScript-like language. Supported: plain and async functions, data literals, destructuring, standard control flow, await and Promise, and built-ins such as Array, Object, Math, JSON, Date, RegExp, Map, Set, and URL. Unsupported: classes, this, getters/setters, tagged templates, BigInt, and custom Symbols. Use plain functions and data objects instead."
export const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError =>
new InterpreterRuntimeError(
export const unsupportedSyntax = (kind: string, node: AstNode): PendingThrow =>
new PendingThrow(
"SyntaxError",
`Syntax '${kind}' is not supported. ${supportedSyntaxMessage}`,
node,
"UnsupportedSyntax",
[supportedSyntaxMessage],
"SyntaxError",
)
export const isRecord = (value: unknown): value is Record<string, unknown> =>
+13 -16
View File
@@ -1,19 +1,19 @@
import { Effect } from "effect"
import type { Prototypes } from "./intrinsics.js"
import { type AstNode, InterpreterRuntimeError } from "./model.js"
import { typeError } from "./model.js"
import { type Callable, define, frozen, hidden, NativeFunction, type NativeOptions, ProgramObject } from "./objects.js"
import { describeValue } from "./references.js"
/** A native function body: a plain value, a thrown `InterpreterRuntimeError`, or an Effect. */
export type Impl = (thisValue: unknown, args: Array<unknown>, node: AstNode) => unknown
/** A native function body: a plain value, a thrown `PendingThrow`, or an Effect. */
export type Impl = (thisValue: unknown, args: Array<unknown>) => unknown
// The dispatch in `Frame.invokeCallable` suspends every native call, so a synchronous throw here is a defect.
const lift =
<R>(impl: Impl) =>
(thisValue: unknown, args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> =>
Effect.suspend(() => {
const result = impl(thisValue, args, node)
return Effect.isEffect(result) ? (result as Effect.Effect<unknown, unknown, R>) : Effect.succeed(result)
})
(thisValue: unknown, args: Array<unknown>): Effect.Effect<unknown, unknown, R> => {
const result = impl(thisValue, args)
return Effect.isEffect(result) ? (result as Effect.Effect<unknown, unknown, R>) : Effect.succeed(result)
}
export const native = <R>(protos: Prototypes, options: NativeOptions<R>): NativeFunction<R> =>
new NativeFunction<R>(protos.Function, options)
@@ -44,12 +44,10 @@ export const constructor = <R>(
}
/** The `call` of a constructor that JS requires to be invoked with `new`. */
export const requiresNew =
(name: string) =>
(_: unknown, __: Array<unknown>, node: AstNode): Effect.Effect<never, unknown, never> =>
Effect.sync(() => {
throw new InterpreterRuntimeError(`Constructor ${name} requires 'new'.`, node)
})
export const requiresNew = (name: string) => (): Effect.Effect<never, unknown, never> =>
Effect.sync(() => {
throw typeError(`Constructor ${name} requires 'new'.`)
})
/** The instance prototype for `new` via `newTarget.prototype`, falling back to the built-in's own. */
export const prototypeFrom = (newTarget: Callable, fallback: ProgramObject): ProgramObject => {
@@ -62,8 +60,7 @@ export const receiver = <T extends ProgramObject>(
cls: abstract new (...args: never) => T,
thisValue: unknown,
method: string,
node?: AstNode,
): T => {
if (thisValue instanceof cls) return thisValue
throw new InterpreterRuntimeError(`${method} called on incompatible receiver ${describeValue(thisValue)}.`, node)
throw typeError(`${method} called on incompatible receiver ${describeValue(thisValue)}.`)
}
+30 -18
View File
@@ -1,6 +1,12 @@
import type { BlockStatement, Expression, Pattern } from "acorn"
import type { Effect, Fiber } from "effect"
import { type AstNode, AsyncIteratorSymbol, type Binding, type GeneratorRequestKind, IteratorSymbol } from "./model.js"
import {
AsyncIteratorSymbol,
type Binding,
type GeneratorRequestKind,
IteratorSymbol,
type PendingThrow,
} from "./model.js"
/** Property attributes, as in a JS property descriptor. */
export type Attributes = {
@@ -42,7 +48,10 @@ export class ProgramArray extends ProgramObject {
}
/** An object with the [[ErrorData]] slot: what `Error.prototype.toString` and the host boundary recognize as an error. */
export class ProgramError extends ProgramObject {}
export class ProgramError extends ProgramObject {
/** The interpreter failure this error materialized from, so rethrowing it keeps the diagnostic kind and location. */
host?: PendingThrow
}
export abstract class Callable extends ProgramObject {
constructor(proto: ProgramObject, name: string, length: number) {
@@ -67,16 +76,8 @@ export class ProgramFunction extends Callable {
}
}
export type NativeCall<R> = (
thisValue: unknown,
args: Array<unknown>,
node: AstNode,
) => Effect.Effect<unknown, unknown, R>
export type NativeConstruct<R> = (
args: Array<unknown>,
newTarget: Callable,
node: AstNode,
) => Effect.Effect<unknown, unknown, R>
export type NativeCall<R> = (thisValue: unknown, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
export type NativeConstruct<R> = (args: Array<unknown>, newTarget: Callable) => Effect.Effect<unknown, unknown, R>
export type NativeOptions<R> = {
readonly name: string
@@ -114,11 +115,7 @@ export class ProgramGenerator extends ProgramObject {
constructor(
proto: ProgramObject,
readonly asynchronous: boolean,
readonly request: (
kind: GeneratorRequestKind,
value: unknown,
node: AstNode,
) => Effect.Effect<unknown, unknown, unknown>,
readonly request: (kind: GeneratorRequestKind, value: unknown) => Effect.Effect<unknown, unknown, unknown>,
) {
super(proto)
}
@@ -171,6 +168,16 @@ export class ProgramURL extends ProgramObject {
}
}
/** An instance of an extension class: the host object lives in a field no property path reaches. */
export class ProgramHandle extends ProgramObject {
constructor(
proto: ProgramObject,
readonly instance: object,
) {
super(proto)
}
}
/** Built-in objects that wrap a host value; data-like, but never plain data. */
export const isWrapper = (
value: unknown,
@@ -290,7 +297,12 @@ export const define = (target: ProgramObject, key: PropertyKey, value: unknown,
target.props.set(name, { value, ...attrs })
}
export const defineAccessor = (target: ProgramObject, key: PropertyKey, get: Getter, set?: Setter): void => {
export const defineAccessor = (
target: ProgramObject,
key: PropertyKey,
get: Getter | undefined,
set?: Setter,
): void => {
target.props.set(canonical(key), { get, set, enumerable: false, configurable: true })
}
+38 -54
View File
@@ -1,6 +1,6 @@
import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect"
import type { Diagnostic } from "../codemode.js"
import { type AstNode, InterpreterRuntimeError, ProgramThrow } from "./model.js"
import { CallSite, ProgramThrow, typeError } from "./model.js"
import {
Callable,
define,
@@ -13,7 +13,7 @@ import {
record,
} from "./objects.js"
import { constructor, fn, methods, native, receiver, requiresNew } from "./native.js"
import { caughtErrorValue, createAggregateErrorValue, normalizeError } from "./errors.js"
import { createAggregateErrorValue, locate, materialize, normalizeError } from "./errors.js"
import { typeofValue } from "./references.js"
import { applyCollectionCallback, isSupportedCallback, type Runner } from "./runner.js"
@@ -49,10 +49,11 @@ export class PromiseRuntime<R> {
}
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<ProgramPromise, never, R> {
return Effect.suspend(() => {
return Effect.flatMap(CallSite, (site) => {
// Allocate before forking so reruns get distinct IDs and diagnostics retain creation order.
const id = this.nextID++
return Effect.map(Effect.forkIn(effect, this.scope, { startImmediately: true }), (fiber) => {
const body = Effect.catchDefect(effect, (defect) => Effect.die(locate(defect, site.node)))
return Effect.map(Effect.forkIn(body, this.scope, { startImmediately: true }), (fiber) => {
const promise = new ProgramPromise(this.proto, fiber)
this.active.add(promise)
this.ids.set(promise, id)
@@ -105,16 +106,14 @@ export class PromiseRuntime<R> {
}
}
export const selfResolutionError = (node?: AstNode): InterpreterRuntimeError =>
new InterpreterRuntimeError("Chaining cycle detected: a promise cannot resolve with itself.", node)
export const resolvePromiseValue = <R>(
runner: Runner<R>,
value: unknown,
node: AstNode,
own?: { promise?: ProgramPromise },
): Effect.Effect<unknown, unknown, R> => {
if (own?.promise !== undefined && value === own.promise) return Effect.fail(selfResolutionError(node))
if (own?.promise !== undefined && value === own.promise) {
return Effect.die(typeError("Chaining cycle detected: a promise cannot resolve with itself."))
}
if (value instanceof ProgramPromise) return runner.settlePromise(value)
if (!(value instanceof ProgramObject)) return Effect.succeed(value)
const then = get(value, "then")
@@ -128,12 +127,12 @@ export const resolvePromiseValue = <R>(
const reject = capability(runner, "reject", (reason) =>
Deferred.doneUnsafe(deferred, Exit.fail(new ProgramThrow(reason))),
)
const executed = yield* Effect.exit(runner.invokeCallable(then, value, [resolve, reject], node))
const executed = yield* Effect.exit(runner.invokeCallable(then, value, [resolve, reject]))
if (!Exit.isSuccess(executed)) {
if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause)
Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause)))
}
return yield* resolvePromiseValue(runner, yield* Deferred.await(deferred), node, own)
return yield* resolvePromiseValue(runner, yield* Deferred.await(deferred), own)
})
}
@@ -141,10 +140,9 @@ export const resolvePromise = <R>(
runner: Runner<R>,
promises: PromiseRuntime<R>,
value: unknown,
node: AstNode,
): Effect.Effect<ProgramPromise, never, R> => {
if (value instanceof ProgramPromise) return Effect.succeed(value)
return promises.createWithSelf((self) => resolvePromiseValue(runner, value, node, self))
return promises.createWithSelf((self) => resolvePromiseValue(runner, value, self))
}
const promiseStatics = ["all", "allSettled", "race", "any", "resolve", "reject"] as const
@@ -154,10 +152,9 @@ const invokePromiseMethod = <R>(
promises: PromiseRuntime<R>,
name: (typeof promiseStatics)[number],
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
if (name === "resolve") {
return resolvePromise(runner, promises, args[0], node)
return resolvePromise(runner, promises, args[0])
}
if (name === "reject") {
return promises.create(Effect.fail(new ProgramThrow(args[0])))
@@ -165,15 +162,13 @@ const invokePromiseMethod = <R>(
return promises.create(
Effect.gen(function* () {
const cursor = yield* runner.syncIterator(args[0], node)
if (cursor === undefined) {
throw new InterpreterRuntimeError(`Promise.${name} expects an array or other synchronous iterable.`, node)
}
const cursor = yield* runner.syncIterator(args[0])
if (cursor === undefined) throw typeError(`Promise.${name} expects an array or other synchronous iterable.`)
const items: Array<ProgramPromise> = []
while (true) {
const step = yield* cursor.next
if (step.done) break
const item = yield* resolvePromise(runner, promises, step.value, node)
const item = yield* resolvePromise(runner, promises, step.value)
promises.markObserved(item)
items.push(item)
}
@@ -201,7 +196,7 @@ const invokePromiseMethod = <R>(
outcomes.push(
record(runner.prototypes.Object, {
status: "rejected",
reason: caughtErrorValue(runner, Cause.squash(exit.cause)),
reason: materialize(runner, Cause.squash(exit.cause)),
}),
)
}
@@ -210,10 +205,7 @@ const invokePromiseMethod = <R>(
}
if (name === "race") {
if (items.length === 0) {
throw new InterpreterRuntimeError(
"Promise.race([]) would never settle; provide at least one promise or value.",
node,
)
throw typeError("Promise.race([]) would never settle; provide at least one promise or value.")
}
return yield* settleAfterTurn(Effect.flatten(Effect.raceAll(items.map((item) => promises.await(item)))))
}
@@ -221,7 +213,7 @@ const invokePromiseMethod = <R>(
Effect.flatMap(promises.await(item), (exit) => {
if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value))
if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause)
return Effect.succeed(caughtErrorValue(runner, Cause.squash(exit.cause)))
return Effect.succeed(materialize(runner, Cause.squash(exit.cause)))
}),
)
return yield* settleAfterTurn(
@@ -244,41 +236,36 @@ const instanceMethod = <R>(
name: "then" | "catch" | "finally",
thisValue: unknown,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<ProgramPromise, unknown, R> => {
const method = `Promise.prototype.${name}`
const promise = receiver(ProgramPromise, thisValue, method, node)
const promise = receiver(ProgramPromise, thisValue, method)
promises.markObserved(promise)
if (name === "finally") {
return chainFinally(runner, promises, promise, reactionHandler(args[0], method, node), method, node)
return chainFinally(runner, promises, promise, reactionHandler(args[0], method), method)
}
const onFulfilled = name === "then" ? reactionHandler(args[0], method, node) : undefined
const onRejected = reactionHandler(name === "then" ? args[1] : args[0], method, node)
return chainReaction(runner, promises, promise, onFulfilled, onRejected, method, node)
const onFulfilled = name === "then" ? reactionHandler(args[0], method) : undefined
const onRejected = reactionHandler(name === "then" ? args[1] : args[0], method)
return chainReaction(runner, promises, promise, onFulfilled, onRejected, method)
}
const constructPromise = <R>(
runner: Runner<R>,
promises: PromiseRuntime<R>,
executor: unknown,
node: AstNode,
): Effect.Effect<ProgramPromise, unknown, R> => {
if (!(executor instanceof ProgramFunction)) {
throw new InterpreterRuntimeError(
"new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).",
node,
)
throw typeError("new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).")
}
return Effect.gen(function* () {
const deferred = Deferred.makeUnsafe<unknown, unknown>()
const promise = yield* promises.createWithSelf((self) =>
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, node, self)),
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, self)),
)
const resolve = capability(runner, "resolve", (value) => Deferred.doneUnsafe(deferred, Exit.succeed(value)))
const reject = capability(runner, "reject", (value) =>
Deferred.doneUnsafe(deferred, Exit.fail(new ProgramThrow(value))),
)
const executed = yield* Effect.exit(runner.invokeCallable(executor, undefined, [resolve, reject], node))
const executed = yield* Effect.exit(runner.invokeCallable(executor, undefined, [resolve, reject]))
if (!Exit.isSuccess(executed)) {
if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause)
Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause)))
@@ -295,12 +282,11 @@ class PromiseAnyFulfilled {
constructor(readonly value: unknown) {}
}
const reactionHandler = (value: unknown, method: string, node: AstNode): Callable | undefined => {
const reactionHandler = (value: unknown, method: string): Callable | undefined => {
if (isSupportedCallback(value)) return value
if (typeofValue(value) === "function") {
throw new InterpreterRuntimeError(
throw typeError(
`${method} cannot use this callable as a handler; wrap it in an arrow function, e.g. (value) => tools.ns.tool(value).`,
node,
)
}
return undefined
@@ -325,16 +311,15 @@ const chainReaction = <R>(
onFulfilled: Callable | undefined,
onRejected: Callable | undefined,
method: string,
node: AstNode,
): Effect.Effect<ProgramPromise, never, R> => {
return promises.createWithSelf((self) =>
Effect.gen(function* () {
const exit = yield* reactionExit(promises, source)
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
if (handler === undefined) return yield* exit
const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(runner, Cause.squash(exit.cause))
const result = yield* applyCollectionCallback(runner, handler, method, node)([input])
return yield* resolvePromiseValue(runner, result, node, self)
const input = Exit.isSuccess(exit) ? exit.value : materialize(runner, Cause.squash(exit.cause))
const result = yield* applyCollectionCallback(runner, handler, method)([input])
return yield* resolvePromiseValue(runner, result, self)
}),
)
}
@@ -345,16 +330,15 @@ const chainFinally = <R>(
source: ProgramPromise,
cleanup: Callable | undefined,
method: string,
node: AstNode,
): Effect.Effect<ProgramPromise, never, R> =>
promises.create(
Effect.gen(function* () {
const exit = yield* reactionExit(promises, source)
if (cleanup !== undefined) {
const result = yield* applyCollectionCallback(runner, cleanup, method, node)([])
const result = yield* applyCollectionCallback(runner, cleanup, method)([])
const intermediate = yield* promises.create(
Effect.gen(function* () {
yield* runner.settlePromise(yield* resolvePromise(runner, promises, result, node))
yield* runner.settlePromise(yield* resolvePromise(runner, promises, result))
return yield* exit
}),
)
@@ -371,7 +355,7 @@ export const promiseGlobal = <R>(runner: Runner<R>, promises: PromiseRuntime<R>)
name: "Promise",
length: 1,
call: requiresNew("Promise"),
construct: (args, _, node) => constructPromise(runner, promises, args[0], node),
construct: (args) => constructPromise(runner, promises, args[0]),
})
// Combinators are not callbacks: `[p].map(Promise.resolve)` must ask for an arrow function.
for (const name of promiseStatics) {
@@ -381,16 +365,16 @@ export const promiseGlobal = <R>(runner: Runner<R>, promises: PromiseRuntime<R>)
native<R>(protos, {
name,
length: 1,
call: (_, args, node) => invokePromiseMethod(runner, promises, name, args, node),
call: (_, args) => invokePromiseMethod(runner, promises, name, args),
callback: false,
}),
hidden,
)
}
methods(protos, proto, [
["then", 2, (thisValue, args, node) => instanceMethod(runner, promises, "then", thisValue, args, node)],
["catch", 1, (thisValue, args, node) => instanceMethod(runner, promises, "catch", thisValue, args, node)],
["finally", 1, (thisValue, args, node) => instanceMethod(runner, promises, "finally", thisValue, args, node)],
["then", 2, (thisValue, args) => instanceMethod(runner, promises, "then", thisValue, args)],
["catch", 1, (thisValue, args) => instanceMethod(runner, promises, "catch", thisValue, args)],
["finally", 1, (thisValue, args) => instanceMethod(runner, promises, "finally", thisValue, args)],
])
return promise
}
@@ -1,5 +1,5 @@
import { ToolReference } from "../tool-runtime.js"
import { type AstNode, InterpreterRuntimeError } from "./model.js"
import { invalidData } from "./model.js"
import {
Callable,
getOwn,
@@ -8,6 +8,7 @@ import {
ProgramArray,
ProgramDate,
ProgramGenerator,
ProgramHandle,
ProgramMap,
ProgramObject,
ProgramPromise,
@@ -21,6 +22,7 @@ import {
export const isRuntimeReference = (value: unknown): boolean =>
value instanceof Callable ||
value instanceof ProgramGenerator ||
value instanceof ProgramHandle ||
value instanceof ToolReference ||
value instanceof ProgramPromise ||
isWrapper(value)
@@ -66,11 +68,10 @@ export const rejectCircularInsertion = (
container: object,
value: unknown,
label: string,
node: AstNode,
seen = new Set<object>(),
): void => {
if (find(value, (current) => current === container, isRuntimeReference, seen)) {
throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
throw invalidData(`${label} contains a circular value.`)
}
}
@@ -86,6 +87,7 @@ export const describeValue = (value: unknown): string => {
if (value instanceof ProgramURL) return "a URL"
if (value instanceof ProgramURLSearchParams) return "a URLSearchParams"
if (value instanceof ProgramGenerator) return "a generator"
if (value instanceof ProgramHandle) return `a ${value.instance.constructor.name}`
if (isRuntimeReference(value)) return "a function"
if (typeof value === "object") return "a data object"
return `a ${typeof value}`
+11 -15
View File
@@ -1,7 +1,7 @@
import { Effect, Exit } from "effect"
import { coerceToNumber, coerceToString } from "../stdlib/value.js"
import type { Prototypes } from "./intrinsics.js"
import { type AstNode, InterpreterRuntimeError } from "./model.js"
import { typeError } from "./model.js"
import { Callable, get, NativeFunction, ProgramDate, ProgramObject, ProgramPromise } from "./objects.js"
import { typeofValue } from "./references.js"
@@ -16,10 +16,9 @@ export type Runner<R> = {
callable: unknown,
thisValue: unknown,
args: Array<unknown>,
node: AstNode,
) => Effect.Effect<unknown, unknown, R>
readonly settlePromise: (promise: ProgramPromise) => Effect.Effect<unknown, unknown, never>
readonly syncIterator: (value: unknown, node: AstNode) => Effect.Effect<IteratorCursor<R> | undefined, unknown, R>
readonly syncIterator: (value: unknown) => Effect.Effect<IteratorCursor<R> | undefined, unknown, R>
readonly prototypes: Prototypes
}
@@ -41,7 +40,6 @@ export const toPrimitive = <R>(
runner: Runner<R>,
value: unknown,
hint: "number" | "string" | "default",
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
if (!(value instanceof ProgramObject)) return Effect.succeed(value)
const asString = hint === "string" || (hint === "default" && value instanceof ProgramDate)
@@ -50,18 +48,18 @@ export const toPrimitive = <R>(
for (const method of order) {
const callable = get(value, method)
if (!(callable instanceof Callable)) continue
const result = yield* runner.invokeCallable(callable, value, [], node)
const result = yield* runner.invokeCallable(callable, value, [])
if (result === null || (typeof result !== "object" && typeof result !== "function")) return result
}
throw new InterpreterRuntimeError("Cannot convert object to primitive value.", node)
throw typeError("Cannot convert object to primitive value.")
})
}
export const toPrimitiveString = <R>(runner: Runner<R>, value: unknown, node: AstNode) =>
Effect.map(toPrimitive(runner, value, "string", node), coerceToString)
export const toPrimitiveString = <R>(runner: Runner<R>, value: unknown) =>
Effect.map(toPrimitive(runner, value, "string"), coerceToString)
export const toPrimitiveNumber = <R>(runner: Runner<R>, value: unknown, node: AstNode) =>
Effect.map(toPrimitive(runner, value, "number", node), coerceToNumber)
export const toPrimitiveNumber = <R>(runner: Runner<R>, value: unknown) =>
Effect.map(toPrimitive(runner, value, "number"), coerceToNumber)
// The single acceptance list for callbacks: collections, sort, string replacers,
// Array.from mappers, and promise reactions all admit exactly these callables.
@@ -74,16 +72,14 @@ export const applyCollectionCallback = <R>(
runner: Runner<R>,
callback: unknown,
name: string,
node: AstNode,
): ((args: Array<unknown>) => Effect.Effect<unknown, unknown, R>) => {
if (!isSupportedCallback(callback)) {
if (typeofValue(callback) === "function") {
throw new InterpreterRuntimeError(
throw typeError(
`${name} cannot use this callable as a callback; wrap it in an arrow function, e.g. (value) => tools.ns.tool(value).`,
node,
)
}
throw new InterpreterRuntimeError(`${name} expects a function callback.`, node)
throw typeError(`${name} expects a function callback.`)
}
return (callbackArgs) => runner.invokeCallable(callback, undefined, callbackArgs, node)
return (callbackArgs) => runner.invokeCallable(callback, undefined, callbackArgs)
}
+161 -139
View File
@@ -48,18 +48,19 @@ import {
type AstNode,
AsyncIteratorSymbol,
type Binding,
CallSite,
type GeneratorRequestKind,
GeneratorReturn,
InterpreterRuntimeError,
IteratorSymbol,
locate,
OptionalShortCircuit,
invalidData,
ProgramThrow,
rangeError,
type StatementResult,
typeError,
unsupportedSyntax,
} from "./model.js"
import { caughtErrorValue } from "./errors.js"
import { locate, materialize } from "./errors.js"
import type { Prototypes } from "./intrinsics.js"
import { globals, type Host } from "./globals.js"
import {
@@ -124,11 +125,11 @@ const calleeDescription = (callee: Expression | Super | undefined): string => {
// OrdinaryHasInstance: walk the left operand's chain looking for the constructor's `prototype`.
const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => {
if (!(rhs instanceof Callable)) {
throw new InterpreterRuntimeError("The right-hand side of 'instanceof' is not callable.", node)
throw typeError("The right-hand side of 'instanceof' is not callable.", node)
}
const prototype = get(rhs, "prototype")
if (!(prototype instanceof ProgramObject)) {
throw new InterpreterRuntimeError("The right-hand side of 'instanceof' has no 'prototype' object.", node)
throw typeError("The right-hand side of 'instanceof' has no 'prototype' object.", node)
}
return hasPrototype(lhs, prototype)
}
@@ -207,7 +208,7 @@ const loopDeclaration = (left: VariableDeclaration | Pattern, statement: "for...
if (left.type !== "VariableDeclaration") return undefined
const declaration = left.declarations.length === 1 ? left.declarations[0] : undefined
if (declaration === undefined) {
throw new InterpreterRuntimeError(`${statement} supports one declared binding.`, left)
throw typeError(`${statement} supports one declared binding.`, left)
}
const kind = left.kind
return {
@@ -246,8 +247,6 @@ type GeneratorState = {
available?: Deferred.Deferred<void>
}
const promiseResolutionNode: AstNode = { type: "PromiseResolution", start: 0, end: 0 }
/** One program execution: the tool bridge, promise scheduler, captured logs, and the global scope built once. */
export class Runtime<R> {
readonly runner: Runner<R>
@@ -266,9 +265,9 @@ export class Runtime<R> {
// Calling back into the program never reads frame state, so any frame serves; the root is always alive.
this.root = new Frame(this, new ScopeStack([globalScope]))
this.runner = {
invokeCallable: (callable, thisValue, args, node) => this.root.invokeCallable(callable, thisValue, args, node),
invokeCallable: (callable, thisValue, args) => this.root.invokeCallable(callable, thisValue, args),
settlePromise: (promise) => this.root.settlePromise(promise),
syncIterator: (value, node) => this.root.syncIterator(value, node),
syncIterator: (value) => this.root.syncIterator(value),
prototypes,
}
for (const [name, value] of [...globals(this), ...extraGlobals(this)]) {
@@ -281,6 +280,8 @@ export class Runtime<R> {
}
}
const MAX_CALL_DEPTH = 10_000
/** One activation: the top-level program or a single function call, evaluating against its own scope chain. */
class Frame<R> {
private generatorState?: GeneratorState
@@ -289,6 +290,8 @@ class Frame<R> {
constructor(
private readonly runtime: Runtime<R>,
private scopes: ScopeStack,
/** Nested call depth; resets when an await resumes, since the continuation runs from the job queue. */
private depth = 0,
) {}
run(program: Program): Effect.Effect<unknown, unknown, R> {
@@ -313,12 +316,12 @@ class Frame<R> {
}
if (result.kind === "break" || result.kind === "continue") {
throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement)
throw typeError(`Unexpected '${result.kind}' outside of a loop.`, statement)
}
}
// The implicit async body adopts returned promises before copy-out.
value = yield* resolvePromiseValue(self.runtime.runner, value, program)
value = yield* resolvePromiseValue(self.runtime.runner, value)
return value
}).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop())))
}
@@ -483,7 +486,7 @@ class Frame<R> {
return Effect.gen(function* () {
const discriminant = yield* self.evaluateExpression(node.discriminant)
if (containsOpaqueReference(discriminant)) {
throw new InterpreterRuntimeError("Switch discriminants must be data values.", node, "InvalidDataValue")
throw invalidData("Switch discriminants must be data values.", node)
}
self.scopes.push()
return yield* Effect.gen(function* () {
@@ -501,7 +504,7 @@ class Frame<R> {
}
const candidate = yield* self.evaluateExpression(test)
if (containsOpaqueReference(candidate)) {
throw new InterpreterRuntimeError("Switch case values must be data values.", test, "InvalidDataValue")
throw invalidData("Switch case values must be data values.", test)
}
if (candidate === discriminant) {
selected = index
@@ -624,7 +627,7 @@ class Frame<R> {
const iterator = yield* self.customIterator(right, node, awaiting)
const cursor = iterator === undefined ? yield* self.syncIterator(right, node) : undefined
if (iterator === undefined && cursor === undefined) {
throw new InterpreterRuntimeError(
throw invalidData(
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams, or custom iterator value.`,
node,
)
@@ -637,7 +640,7 @@ class Frame<R> {
: (cursor?.close ?? Effect.void)
if (left.type === "RestElement" || left.type === "AssignmentPattern") {
throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
throw typeError("Unsupported for...of binding.", left)
}
const assignment = left.type === "VariableDeclaration" ? undefined : left
@@ -664,7 +667,7 @@ class Frame<R> {
while (true) {
const current = iterator
? yield* self.nextIteratorResult(iterator, node, awaiting)
: yield* cursor?.next ?? Effect.fail(new InterpreterRuntimeError("Iterator is unavailable.", node))
: yield* cursor?.next ?? Effect.die(typeError("Iterator is unavailable.", node))
const step = cursor && awaiting ? { done: current.done, value: yield* self.awaitValue(current.value) } : current
if (step.done) return { kind: "none" } satisfies StatementResult
const bodyExit = yield* Effect.exit(evaluateBody(step.value))
@@ -690,16 +693,19 @@ class Frame<R> {
)
}
private awaitValue(value: unknown, node: AstNode = promiseResolutionNode): Effect.Effect<unknown, unknown, R> {
return Effect.flatMap(resolvePromise(this.runtime.runner, this.runtime.promises, value, node), (promise) =>
this.settlePromise(promise),
private awaitValue(value: unknown): Effect.Effect<unknown, unknown, R> {
return Effect.flatMap(resolvePromise(this.runtime.runner, this.runtime.promises, value), (promise) =>
Effect.ensuring(
this.settlePromise(promise),
Effect.sync(() => (this.depth = 0)),
),
)
}
private awaitAsyncFromSyncValue(
iterator: CustomIterator,
value: unknown,
node: AstNode,
node: AstNode | undefined,
closeOnRejection: boolean,
): Effect.Effect<unknown, unknown, R> {
const self = this
@@ -713,7 +719,7 @@ class Frame<R> {
})
}
syncIterator(value: unknown, node: AstNode) {
syncIterator(value: unknown, node?: AstNode) {
const iterator =
value instanceof ProgramArray
? value.items[Symbol.iterator]()
@@ -750,7 +756,7 @@ class Frame<R> {
)
}
private customIterator(value: unknown, node: AstNode, allowAsync = true) {
private customIterator(value: unknown, node: AstNode | undefined, allowAsync = true) {
if (!(value instanceof ProgramObject)) return Effect.undefined
const asyncMethod = allowAsync ? get(value, AsyncIteratorSymbol) : undefined
const method = asyncMethod ?? get(value, IteratorSymbol)
@@ -769,7 +775,7 @@ class Frame<R> {
)
}
private nextIteratorResult(iterator: CustomIterator, node: AstNode, awaiting: boolean) {
private nextIteratorResult(iterator: CustomIterator, node: AstNode | undefined, awaiting: boolean) {
const self = this
return Effect.gen(function* () {
if (iterator.asynchronous) {
@@ -805,7 +811,11 @@ class Frame<R> {
})
}
private closeIterator(iterator: CustomIterator, node: AstNode, awaiting = true): Effect.Effect<void, unknown, R> {
private closeIterator(
iterator: CustomIterator,
node: AstNode | undefined,
awaiting = true,
): Effect.Effect<void, unknown, R> {
const close = get(iterator.iterator, "return")
if (close === undefined || close === null) return iterator.asynchronous || !awaiting ? Effect.void : Effect.yieldNow
const self = this
@@ -836,14 +846,14 @@ class Frame<R> {
})
}
private requireIteratorObject(value: unknown, context: string, node: AstNode): ProgramObject {
private requireIteratorObject(value: unknown, context: string, node?: AstNode): ProgramObject {
if (value instanceof ProgramObject) return value
throw new InterpreterRuntimeError(`${context} must be an object.`, node)
throw typeError(`${context} must be an object.`, node)
}
private requireIteratorMethod(value: unknown, context: string, node: AstNode): unknown {
private requireIteratorMethod(value: unknown, context: string, node?: AstNode): unknown {
if (typeofValue(value) === "function") return value
throw new InterpreterRuntimeError(`${context} must be a function.`, node)
throw typeError(`${context} must be a function.`, node)
}
// for...in over null/undefined iterates nothing, like JS.
@@ -869,7 +879,7 @@ class Frame<R> {
const keys = self.enumerableKeys(right, node.right)
if (left.type !== "Identifier" && left.type !== "VariableDeclaration") {
throw new InterpreterRuntimeError("Unsupported for...in binding.", left)
throw typeError("Unsupported for...in binding.", left)
}
const assignmentName = left.type === "Identifier" ? left.name : undefined
@@ -955,7 +965,7 @@ class Frame<R> {
return Effect.failCause(cause)
}
const caught = caughtErrorValue(self.runtime.runner, Cause.squash(cause))
const caught = materialize(self.runtime.runner, Cause.squash(cause))
const parameter = handler.param
self.scopes.push()
return Effect.gen(function* () {
@@ -991,7 +1001,7 @@ class Frame<R> {
return Effect.gen(function* () {
for (const declaration of node.declarations) {
if (declaration.type !== "VariableDeclarator") {
throw new InterpreterRuntimeError("Unsupported variable declaration shape.", declaration)
throw typeError("Unsupported variable declaration shape.", declaration)
}
const init = declaration.init
@@ -1033,10 +1043,9 @@ class Frame<R> {
if (pattern.type === "ObjectPattern") {
if (!(value instanceof ProgramObject)) {
throw new InterpreterRuntimeError(
throw typeError(
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
pattern,
"InvalidDataValue",
)
}
@@ -1051,7 +1060,13 @@ class Frame<R> {
const key = yield* self.destructuringPropertyKey(property)
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.declarePattern(property.value, get(value, key), mutable, property, initialize)
yield* self.declarePattern(
property.value,
self.readProperty(value, key, property),
mutable,
property,
initialize,
)
}
return
}
@@ -1062,7 +1077,7 @@ class Frame<R> {
)
}
throw new InterpreterRuntimeError(`Unsupported binding pattern '${pattern.type}'.`, pattern)
throw typeError(`Unsupported binding pattern '${pattern.type}'.`, pattern)
})
}
@@ -1087,10 +1102,9 @@ class Frame<R> {
if (pattern.type === "ObjectPattern") {
if (!(value instanceof ProgramObject)) {
throw new InterpreterRuntimeError(
throw invalidData(
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
pattern,
"InvalidDataValue",
)
}
@@ -1104,7 +1118,7 @@ class Frame<R> {
}
const key = yield* self.destructuringPropertyKey(property)
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.assignPattern(property.value, get(value, key), property)
yield* self.assignPattern(property.value, self.readProperty(value, key, property), property)
}
return
}
@@ -1115,7 +1129,7 @@ class Frame<R> {
)
}
throw new InterpreterRuntimeError(`Unsupported assignment pattern '${pattern.type}'.`, node)
throw typeError(`Unsupported assignment pattern '${pattern.type}'.`, node)
})
}
@@ -1134,7 +1148,7 @@ class Frame<R> {
return Effect.gen(function* () {
const cursor = yield* self.syncIterator(value, pattern)
if (cursor === undefined) {
throw new InterpreterRuntimeError("Array destructuring requires a supported iterable value.", pattern)
throw typeError("Array destructuring requires a supported iterable value.", pattern)
}
let done = false
for (const element of pattern.elements) {
@@ -1171,7 +1185,7 @@ class Frame<R> {
private destructuringPropertyKey(property: Property | AssignmentProperty): Effect.Effect<PropertyKey, unknown, R> {
if (property.type !== "Property" || property.kind !== "init") {
throw new InterpreterRuntimeError("Unsupported object destructuring property.", property)
throw typeError("Unsupported object destructuring property.", property)
}
const keyNode = property.key
if (property.computed) {
@@ -1186,8 +1200,7 @@ class Frame<R> {
switch (node.type) {
case "Literal": {
const regex = node.regex
if (regex)
return Effect.sync(() => constructRegExp(this.runtime.prototypes, [regex.pattern, regex.flags], node))
if (regex) return Effect.sync(() => constructRegExp(this.runtime.prototypes, [regex.pattern, regex.flags]))
return Effect.sync(() => toProgram(this.runtime.prototypes, node.value, "Literal"))
}
case "Identifier":
@@ -1233,7 +1246,7 @@ class Frame<R> {
return this.evaluateUpdateExpression(node)
case "AwaitExpression": {
// Await always suspends, including for plain values.
return Effect.flatMap(this.evaluateExpression(node.argument), (value) => this.awaitValue(value, node))
return Effect.flatMap(this.evaluateExpression(node.argument), (value) => this.awaitValue(value))
}
case "YieldExpression":
return this.evaluateYieldExpression(node)
@@ -1261,10 +1274,10 @@ class Frame<R> {
: callee instanceof NativeFunction
? `new ${name}(...) is not supported; call ${name}(...) without new instead.`
: `${name} is not a constructor.`
throw new InterpreterRuntimeError(message, node)
throw typeError(message, node)
}
const args = yield* self.evaluateCallArguments(node.arguments)
return yield* construct(args, callee as NativeFunction<R>, node)
return yield* self.native(() => construct(args, callee as NativeFunction<R>), node)
})
}
@@ -1292,7 +1305,7 @@ class Frame<R> {
return has(rhs, lhs !== null && typeof lhs === "object" ? coerceToString(lhs) : (lhs as PropertyKey))
}
if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) {
throw new InterpreterRuntimeError("Binary operators require data values.", node, "InvalidDataValue")
throw invalidData("Binary operators require data values.", node)
}
// Null-prototype data needs explicit primitive coercion; identity and `in` retain raw objects.
// Dates use their default string hint for addition and loose equality, and epoch time elsewhere.
@@ -1344,11 +1357,11 @@ class Frame<R> {
return (l as number) >>> (r as number)
case "in":
if (!(rhs instanceof ProgramObject)) {
throw new InterpreterRuntimeError("The 'in' operator requires a data object on the right-hand side.", node)
throw typeError("The 'in' operator requires a data object on the right-hand side.", node)
}
return has(rhs, coerceOperand(lhs) as PropertyKey)
default:
throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node)
throw typeError(`Unsupported binary operator '${operator}'.`, node)
}
}
@@ -1359,7 +1372,7 @@ class Frame<R> {
if (operator === "||") return left ? Effect.succeed(left) : this.evaluateExpression(node.right)
if (operator === "??")
return left !== null && left !== undefined ? Effect.succeed(left) : this.evaluateExpression(node.right)
throw new InterpreterRuntimeError(`Unsupported logical operator '${operator}'.`, node)
throw typeError(`Unsupported logical operator '${operator}'.`, node)
})
}
@@ -1376,7 +1389,7 @@ class Frame<R> {
if (operator === "!") return !value
if (operator === "void") return undefined
if (containsOpaqueReference(value)) {
throw new InterpreterRuntimeError("Unary operators require data values.", node, "InvalidDataValue")
throw invalidData("Unary operators require data values.", node)
}
const operand =
value instanceof ProgramDate
@@ -1396,7 +1409,7 @@ class Frame<R> {
result = ~(operand as number)
break
default:
throw new InterpreterRuntimeError(`Unsupported unary operator '${operator}'.`, node)
throw typeError(`Unsupported unary operator '${operator}'.`, node)
}
return toProgram(this.runtime.prototypes, result, "Unary expression result")
})
@@ -1443,7 +1456,7 @@ class Frame<R> {
}),
)
}
throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left)
throw typeError("Assignment target must be an Identifier or MemberExpression.", left)
})
}
@@ -1475,7 +1488,7 @@ class Frame<R> {
: Effect.succeed({ write: false, next: current, result: current }),
)
}
throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left)
throw typeError("Assignment target must be an Identifier or MemberExpression.", left)
}
private evaluateUpdateExpression(node: UpdateExpression): Effect.Effect<unknown, unknown, R> {
@@ -1486,14 +1499,14 @@ class Frame<R> {
const increment = operator === "++" ? 1 : operator === "--" ? -1 : undefined
if (increment === undefined) {
throw new InterpreterRuntimeError(`Unsupported update operator '${operator}'.`, node)
throw typeError(`Unsupported update operator '${operator}'.`, node)
}
// CodeMode numeric coercion, not host Number(): null-prototype data objects would make
// the host throw during ToPrimitive, and opaque runtime references must reject clearly.
const operand = (current: unknown): number => {
if (containsOpaqueReference(current)) {
throw new InterpreterRuntimeError(`'${operator}' requires a data value.`, argument, "InvalidDataValue")
throw invalidData(`'${operator}' requires a data value.`, argument)
}
return coerceToNumber(current)
}
@@ -1516,7 +1529,7 @@ class Frame<R> {
})
}
throw new InterpreterRuntimeError("Update target must be an Identifier or MemberExpression.", argument)
throw typeError("Update target must be an Identifier or MemberExpression.", argument)
}
// EvaluateCall: a member callee supplies its base object as `this`; anything else calls with undefined.
@@ -1552,23 +1565,34 @@ class Frame<R> {
callable: unknown,
thisValue: unknown,
args: Array<unknown>,
node: AstNode,
node?: AstNode,
callee?: Expression,
): Effect.Effect<unknown, unknown, R> {
const self = this
return Effect.gen(function* () {
if (callable instanceof ToolReference) {
if (callable.path.length === 0) {
throw new InterpreterRuntimeError("The tools root is not callable.", callee ?? node)
throw typeError("The tools root is not callable.", callee ?? node)
}
return yield* self.createToolCallPromise(callable.path, args)
}
if (callable instanceof ProgramFunction) return yield* self.invokeFunction(callable, args)
if (callable instanceof NativeFunction) return yield* (callable as NativeFunction<R>).call(thisValue, args, node)
throw new InterpreterRuntimeError(`${calleeDescription(callee)} is not a function.`, callee ?? node)
if (callable instanceof ProgramFunction) return yield* self.invokeFunction(callable, args, node)
if (callable instanceof NativeFunction) {
return yield* self.native(() => (callable as NativeFunction<R>).call(thisValue, args), node)
}
throw typeError(`${calleeDescription(callee)} is not a function.`, callee ?? node)
})
}
// Built-ins throw without a location, synchronously or inside their Effect; the call site supplies it.
private native(body: () => Effect.Effect<unknown, unknown, R>, node?: AstNode): Effect.Effect<unknown, unknown, R> {
return Effect.provideService(
Effect.catchDefect(Effect.suspend(body), (defect) => Effect.die(locate(defect, node))),
CallSite,
{ node, depth: this.depth },
)
}
private evaluateCallArguments(
argNodes: ReadonlyArray<Expression | SpreadElement>,
): Effect.Effect<Array<unknown>, unknown, R> {
@@ -1579,8 +1603,7 @@ class Frame<R> {
if (argNode.type === "SpreadElement") {
const spread = yield* self.evaluateExpression(argNode.argument)
const cursor = yield* self.syncIterator(spread, argNode)
if (cursor === undefined)
throw new InterpreterRuntimeError("Spread arguments require a synchronous iterable.", argNode)
if (cursor === undefined) throw typeError("Spread arguments require a synchronous iterable.", argNode)
while (true) {
const step = yield* cursor.next
if (step.done) break
@@ -1594,45 +1617,50 @@ class Frame<R> {
})
}
invokeFunction(fn: ProgramFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
// A callback invoked by a built-in runs below the call that invoked the built-in, so the deeper of the two counts.
invokeFunction(fn: ProgramFunction, args: Array<unknown>, node?: AstNode): Effect.Effect<unknown, unknown, R> {
const self = this
const invocation = new Frame(this.runtime, new ScopeStack([...fn.capturedScopes, new Map()]))
const run = Effect.gen(function* () {
// Seed all parameters first so defaults cannot fall through to same-named outer bindings.
const paramScope = invocation.scopes.current()
for (const parameter of fn.parameters) {
for (const name of collectPatternNames(parameter)) {
paramScope.set(name, { mutable: true, value: undefined, initialized: false })
return Effect.flatMap(CallSite, (site) => {
const depth = Math.max(self.depth, site.depth) + 1
if (depth > MAX_CALL_DEPTH) throw rangeError("Maximum call stack size exceeded", node)
const invocation = new Frame(this.runtime, new ScopeStack([...fn.capturedScopes, new Map()]), depth)
const run = Effect.gen(function* () {
// Seed all parameters first so defaults cannot fall through to same-named outer bindings.
const paramScope = invocation.scopes.current()
for (const parameter of fn.parameters) {
for (const name of collectPatternNames(parameter)) {
paramScope.set(name, { mutable: true, value: undefined, initialized: false })
}
}
}
for (const [index, parameter] of fn.parameters.entries()) {
if (parameter.type === "RestElement") {
yield* invocation.declarePattern(
parameter.argument,
new ProgramArray(self.runtime.prototypes.Array, args.slice(index)),
true,
parameter,
true,
)
break
for (const [index, parameter] of fn.parameters.entries()) {
if (parameter.type === "RestElement") {
yield* invocation.declarePattern(
parameter.argument,
new ProgramArray(self.runtime.prototypes.Array, args.slice(index)),
true,
parameter,
true,
)
break
}
yield* invocation.declarePattern(parameter, args[index], true, parameter, true)
}
yield* invocation.declarePattern(parameter, args[index], true, parameter, true)
}
if (fn.body.type === "BlockStatement") {
invocation.scopes.push()
invocation.hoistVars(fn.body.body, paramScope)
const result = yield* invocation.evaluateStatement(fn.body)
return result.kind === "return" ? result.value : undefined
}
if (fn.body.type === "BlockStatement") {
invocation.scopes.push()
invocation.hoistVars(fn.body.body, paramScope)
const result = yield* invocation.evaluateStatement(fn.body)
return result.kind === "return" ? result.value : undefined
}
return yield* invocation.evaluateExpression(fn.body)
return yield* invocation.evaluateExpression(fn.body)
})
if (fn.generator) return Effect.succeed(this.createGenerator(invocation, run, fn.async))
if (!fn.async) return run
return this.runtime.promises.createWithSelf((self) =>
Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runtime.runner, value, self)),
)
})
if (fn.generator) return Effect.succeed(this.createGenerator(invocation, run, fn.async))
if (!fn.async) return run
return this.runtime.promises.createWithSelf((self) =>
Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runtime.runner, value, fn.body, self)),
)
}
private createGenerator(
@@ -1645,11 +1673,9 @@ class Frame<R> {
invocation.generatorAsync = asynchronous
const protos = this.runtime.prototypes
const result = (value: unknown, done: boolean) => record(protos.Object, { value, done })
const request = (kind: GeneratorRequestKind, value: unknown, node: AstNode) => {
const request = (kind: GeneratorRequestKind, value: unknown) => {
const request = { kind, value, response: Deferred.makeUnsafe<unknown, unknown>() }
if (!asynchronous && state.active) {
return Effect.fail(new InterpreterRuntimeError("Generator is already running.", node))
}
if (!asynchronous && state.active) return Effect.die(typeError("Generator is already running."))
if (asynchronous && (state.completed || (!state.started && kind !== "next"))) {
state.started = true
state.completed = true
@@ -1769,7 +1795,7 @@ class Frame<R> {
const argument = node.argument
const self = this
return Effect.gen(function* () {
if (!self.generatorState) throw new InterpreterRuntimeError("yield is only valid inside a generator.", node)
if (!self.generatorState) throw typeError("yield is only valid inside a generator.", node)
if (node.delegate) {
const value = argument ? yield* self.evaluateExpression(argument) : undefined
return yield* self.delegateYield(value, node)
@@ -1782,7 +1808,7 @@ class Frame<R> {
private suspendGenerator(value: unknown, node: AstNode): Effect.Effect<unknown, unknown, R> {
const state = this.generatorState
if (!state?.active) throw new InterpreterRuntimeError("Generator has no active request.", node)
if (!state?.active) throw typeError("Generator has no active request.", node)
Deferred.doneUnsafe(
state.active.response,
Exit.succeed(record(this.runtime.prototypes.Object, { value, done: false })),
@@ -1809,7 +1835,7 @@ class Frame<R> {
value instanceof ProgramURLSearchParams
) {
const cursor = yield* self.syncIterator(value, node)
if (!cursor) throw new InterpreterRuntimeError("Built-in iterator is unavailable.", node)
if (!cursor) throw typeError("Built-in iterator is unavailable.", node)
while (true) {
const step = yield* cursor.next
if (step.done) return undefined
@@ -1824,14 +1850,14 @@ class Frame<R> {
}
if (error instanceof ProgramThrow) {
yield* cursor.close
throw new InterpreterRuntimeError("The delegated iterator does not provide a throw() method.", node)
throw typeError("The delegated iterator does not provide a throw() method.", node)
}
return yield* Effect.failCause(resumed.cause)
}
}
const iterator = yield* self.customIterator(value, node, self.generatorAsync)
if (!iterator) throw new InterpreterRuntimeError("yield* requires a compatible iterable value.", node)
if (!iterator) throw typeError("yield* requires a compatible iterable value.", node)
let kind: GeneratorRequestKind = "next"
let input: unknown = undefined
while (true) {
@@ -1839,7 +1865,7 @@ class Frame<R> {
if (method === undefined || method === null) {
if (kind === "return") return yield* Effect.fail(new GeneratorReturn(input))
yield* self.closeIterator(iterator, node, self.generatorAsync)
throw new InterpreterRuntimeError("The delegated iterator does not provide a throw() method.", node)
throw typeError("The delegated iterator does not provide a throw() method.", node)
}
const called = yield* self.invokeCallable(
self.requireIteratorMethod(method, `Iterator ${kind}`, node),
@@ -1891,7 +1917,7 @@ class Frame<R> {
}
if (property.kind !== "init") {
throw new InterpreterRuntimeError("Only init object properties are supported.", property)
throw typeError("Only init object properties are supported.", property)
}
const keyNode = property.key
@@ -1905,7 +1931,7 @@ class Frame<R> {
} else if (keyNode.type === "Literal") {
key = self.toPropertyKey(keyNode.value, keyNode)
} else {
throw new InterpreterRuntimeError("Unsupported object property key shape.", keyNode)
throw typeError("Unsupported object property key shape.", keyNode)
}
const name =
@@ -1935,8 +1961,7 @@ class Frame<R> {
if (element.type === "SpreadElement") {
const spread = yield* self.evaluateExpression(element.argument)
const cursor = yield* self.syncIterator(spread, element)
if (cursor === undefined)
throw new InterpreterRuntimeError("Array spread requires a synchronous iterable.", element)
if (cursor === undefined) throw typeError("Array spread requires a synchronous iterable.", element)
while (true) {
const step = yield* cursor.next
if (step.done) break
@@ -1962,7 +1987,7 @@ class Frame<R> {
const quasi = quasis[index]!
// acorn only omits `cooked` for invalid escapes in tagged templates, which are unsupported.
if (typeof quasi.value.cooked !== "string") {
throw new InterpreterRuntimeError("Invalid template literal quasi.", quasi)
throw typeError("Invalid template literal quasi.", quasi)
}
output += quasi.value.cooked
@@ -1984,7 +2009,7 @@ class Frame<R> {
private applyCompoundAssignment(operator: string, current: unknown, incoming: unknown, node: AstNode): unknown {
if (!compoundOperators.has(operator)) {
throw new InterpreterRuntimeError(`Unsupported assignment operator '${operator}'.`, node)
throw typeError(`Unsupported assignment operator '${operator}'.`, node)
}
return this.applyBinaryOperator(operator.slice(0, -1), current, incoming, node)
}
@@ -2010,7 +2035,7 @@ class Frame<R> {
if (objectValue instanceof ToolReference) {
if (typeof key !== "string") {
throw new InterpreterRuntimeError("Tool paths must use string property names.", propertyNode)
throw typeError("Tool paths must use string property names.", propertyNode)
}
return new ToolReference([...objectValue.path, key])
}
@@ -2029,26 +2054,27 @@ class Frame<R> {
if (typeof objectValue === "boolean") return { target: protos.Boolean, key, receiver: objectValue }
if (objectValue === null || objectValue === undefined) {
throw new InterpreterRuntimeError(
`Cannot read properties of ${objectValue} (reading '${String(key)}').`,
objectNode,
)
throw typeError(`Cannot read properties of ${objectValue} (reading '${String(key)}').`, objectNode)
}
throw new InterpreterRuntimeError("Cannot access a property on a non-object value.", objectNode)
throw typeError("Cannot access a property on a non-object value.", objectNode)
})
}
private readReference(reference: MemberReference, node: MemberExpression): unknown {
// Reject unknown promise properties so a missing await cannot hide.
if (reference.target instanceof ProgramPromise && !has(reference.target, reference.key)) {
throw new InterpreterRuntimeError(
throw invalidData(
"This value is an un-awaited Promise; await it first - e.g. `const result = await tools.ns.tool(...)`.",
node.object,
"InvalidDataValue",
)
}
return this.readProperty(reference.target, reference.key, node, reference.receiver)
}
// Accessors throw without a location; the member or pattern that read them supplies it.
private readProperty(target: ProgramObject, key: PropertyKey, node: AstNode, receiver: unknown = target): unknown {
try {
return get(reference.target, reference.key, reference.receiver)
return get(target, key, receiver)
} catch (error) {
throw locate(error, node)
}
@@ -2070,15 +2096,15 @@ class Frame<R> {
private evaluateDeleteExpression(argument: Expression): Effect.Effect<boolean, unknown, R> {
const target = argument.type === "ChainExpression" ? argument.expression : argument
if (target.type !== "MemberExpression") {
throw new InterpreterRuntimeError("Only data fields may be deleted.", argument)
throw typeError("Only data fields may be deleted.", argument)
}
return Effect.map(this.getMemberReference(target), (reference) => {
if (reference === OptionalShortCircuit) return true
if (reference instanceof ToolReference || "value" in reference || reference.receiver !== reference.target) {
throw new InterpreterRuntimeError("Only data fields may be deleted.", target, "InvalidDataValue")
throw invalidData("Only data fields may be deleted.", target)
}
if (remove(reference.target, reference.key)) return true
throw new InterpreterRuntimeError(`Cannot delete property '${String(reference.key)}'.`, target)
throw typeError(`Cannot delete property '${String(reference.key)}'.`, target)
})
}
@@ -2091,10 +2117,10 @@ class Frame<R> {
return Effect.gen(function* () {
const reference = yield* self.getMemberReference(node)
if (reference === OptionalShortCircuit || reference instanceof ToolReference || "value" in reference) {
throw new InterpreterRuntimeError("Only data fields may be assigned.", node)
throw typeError("Only data fields may be assigned.", node)
}
if (reference.receiver !== reference.target) {
throw new InterpreterRuntimeError(
throw typeError(
`Cannot create property '${String(reference.key)}' on ${typeof reference.receiver} '${String(reference.receiver)}'.`,
node,
)
@@ -2107,14 +2133,13 @@ class Frame<R> {
}
private assignToReference(target: ProgramObject, key: PropertyKey, next: unknown, node: AstNode): void {
rejectCircularInsertion(
target,
next,
target instanceof ProgramArray ? "Array assignment result" : "Object assignment result",
node,
)
const written = (() => {
try {
rejectCircularInsertion(
target,
next,
target instanceof ProgramArray ? "Array assignment result" : "Object assignment result",
)
return set(target, key, next)
} catch (error) {
throw locate(error, node)
@@ -2122,7 +2147,7 @@ class Frame<R> {
})()
if (written) return
if (target instanceof ProgramArray && key === "length") throw rangeError("Invalid array length", node)
throw new InterpreterRuntimeError(`Cannot assign to read only property '${String(key)}'.`, node)
throw typeError(`Cannot assign to read only property '${String(key)}'.`, node)
}
private toPropertyKey(value: unknown, node: AstNode): PropertyKey {
@@ -2131,9 +2156,6 @@ class Frame<R> {
}
if (value === AsyncIteratorSymbol || value === IteratorSymbol) return value
throw new InterpreterRuntimeError(
"Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator.",
node,
)
throw typeError("Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator.", node)
}
}
+6 -6
View File
@@ -1,4 +1,4 @@
import { type AstNode, type Binding, InterpreterRuntimeError, referenceError } from "./model.js"
import { type AstNode, type Binding, referenceError, typeError } from "./model.js"
export class ScopeStack {
private readonly scopes: Array<Map<string, Binding>>
@@ -10,7 +10,7 @@ export class ScopeStack {
reserve(name: string, mutable: boolean, node: AstNode): void {
const scope = this.current()
if (scope.has(name)) {
throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node)
throw typeError(`Identifier '${name}' has already been declared.`, node)
}
scope.set(name, { mutable, value: undefined, initialized: false })
}
@@ -18,7 +18,7 @@ export class ScopeStack {
initialize(name: string, value: unknown, node: AstNode): void {
const binding = this.current().get(name)
if (!binding || binding.initialized !== false) {
throw new InterpreterRuntimeError(`Identifier '${name}' has not been reserved for initialization.`, node)
throw typeError(`Identifier '${name}' has not been reserved for initialization.`, node)
}
binding.value = value
binding.initialized = true
@@ -27,7 +27,7 @@ export class ScopeStack {
declare(name: string, value: unknown, mutable: boolean, node: AstNode): void {
const scope = this.current()
if (scope.has(name)) {
throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node)
throw typeError(`Identifier '${name}' has already been declared.`, node)
}
scope.set(name, { mutable, value, initialized: true })
}
@@ -58,7 +58,7 @@ export class ScopeStack {
}
if (!binding.mutable) {
throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node)
throw typeError(`Cannot assign to constant '${name}'.`, node)
}
binding.value = value
@@ -82,7 +82,7 @@ export class ScopeStack {
const scope = this.scopes[this.scopes.length - 1]
if (!scope) {
throw new InterpreterRuntimeError("Interpreter scope stack is empty.")
throw typeError("Interpreter scope stack is empty.")
}
return scope
+89 -101
View File
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
import { type AstNode, InterpreterRuntimeError, rangeError } from "../interpreter/model.js"
import { invalidData, rangeError, typeError } from "../interpreter/model.js"
import { get, ProgramArray, ProgramGenerator, ProgramObject } from "../interpreter/objects.js"
import { describeValue, rejectCircularInsertion } from "../interpreter/references.js"
import { applyCollectionCallback, preserveConsumerError, type Runner } from "../interpreter/runner.js"
@@ -9,35 +9,30 @@ import { coerceToNumber, coerceToString } from "./value.js"
const MAX_LENGTH = 4_294_967_295
const arrayLikeSource = (
source: unknown,
node: AstNode,
): { readonly length: number; readonly source: ProgramObject } => {
const arrayLikeSource = (source: unknown): { readonly length: number; readonly source: ProgramObject } => {
if (source instanceof ProgramObject && typeof get(source, "length") === "number") {
const length = get(source, "length") as number
const normalized = Number.isNaN(length) || length <= 0 ? 0 : Math.trunc(length)
if (normalized > MAX_LENGTH) throw new RangeError("Invalid array length")
return { length: normalized, source }
}
throw new InterpreterRuntimeError(
throw invalidData(
`Array.from expects an array, string, Map, Set, or array-like value, received ${describeValue(source)}.`,
node,
"InvalidDataValue",
)
}
const arrayFrom = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> => {
const arrayFrom = <R>(runner: Runner<R>, args: Array<unknown>): Effect.Effect<unknown, unknown, R> => {
const source = args[0]
const proto = runner.prototypes.Array
const apply =
args.length < 2 || args[1] === undefined ? undefined : applyCollectionCallback(runner, args[1], "Array.from", node)
args.length < 2 || args[1] === undefined ? undefined : applyCollectionCallback(runner, args[1], "Array.from")
return Effect.gen(function* () {
const cursor = yield* runner.syncIterator(source, node)
const cursor = yield* runner.syncIterator(source)
if (cursor === undefined) {
if (source instanceof ProgramGenerator) {
throw new InterpreterRuntimeError("Array.from expects a synchronous iterable or array-like value.", node)
throw typeError("Array.from expects a synchronous iterable or array-like value.")
}
const arrayLike = arrayLikeSource(source, node)
const arrayLike = arrayLikeSource(source)
const values: Array<unknown> = []
for (let index = 0; index < arrayLike.length; index += 1) {
const item = get(arrayLike.source, index)
@@ -61,12 +56,11 @@ export const sortArray = <R>(
target: Array<unknown>,
comparator: unknown,
name: string,
node: AstNode,
): Effect.Effect<Array<unknown>, unknown, R> => {
if (comparator === undefined) {
return Effect.sync(() => [...target].sort((a, b) => compareText(coerceToString(a), coerceToString(b))))
}
const apply = applyCollectionCallback(runner, comparator, name, node)
const apply = applyCollectionCallback(runner, comparator, name)
const mergeSort = (items: Array<unknown>): Effect.Effect<Array<unknown>, unknown, R> => {
if (items.length <= 1) return Effect.succeed(items)
const midpoint = Math.floor(items.length / 2)
@@ -95,32 +89,31 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
const protos = runner.prototypes
const proto = protos.Array
const wrap = (items: Array<unknown>) => new ProgramArray(proto, items)
const construct = (args: Array<unknown>, into: ProgramObject, node: AstNode): ProgramArray => {
const construct = (args: Array<unknown>, into: ProgramObject): ProgramArray => {
if (args.length !== 1) return new ProgramArray(into, [...args])
const first = args[0]
if (typeof first !== "number") return new ProgramArray(into, [first])
if (!Number.isInteger(first) || first < 0 || first > MAX_LENGTH) throw rangeError("Invalid array length.", node)
if (!Number.isInteger(first) || first < 0 || first > MAX_LENGTH) throw rangeError("Invalid array length.")
// Sparse like JS: Array(3) has holes, and combinator loops already skip them.
return new ProgramArray(into, new Array(first))
}
const array = constructor<R>(protos, proto, {
name: "Array",
length: 1,
call: (_, args, node) => Effect.sync(() => construct(args, proto, node)),
construct: (args, newTarget, node) => Effect.sync(() => construct(args, prototypeFrom(newTarget, proto), node)),
call: (_, args) => Effect.sync(() => construct(args, proto)),
construct: (args, newTarget) => Effect.sync(() => construct(args, prototypeFrom(newTarget, proto))),
})
methods(protos, array, [
["isArray", 1, (_, args) => args[0] instanceof ProgramArray],
["of", 0, (_, args) => wrap([...args])],
["from", 1, (_, args, node) => arrayFrom(runner, args, node)],
["from", 1, (_, args) => arrayFrom(runner, args)],
])
const self = (thisValue: unknown, name: string, node: AstNode) =>
receiver(ProgramArray, thisValue, `Array.prototype.${name}`, node)
const optNumber = (name: string, value: unknown, label: string, node: AstNode): number | undefined => {
const self = (thisValue: unknown, name: string) => receiver(ProgramArray, thisValue, `Array.prototype.${name}`)
const optNumber = (name: string, value: unknown, label: string): number | undefined => {
if (value === undefined) return undefined
if (typeof value !== "number") {
throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node)
throw typeError(`Array.${name} expects ${label} to be a number.`)
}
return value
}
@@ -133,14 +126,13 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
receiver: ProgramArray,
apply: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
args: Array<unknown>,
node: AstNode,
) => Effect.Effect<unknown, unknown, R>,
): Method => [
name,
length,
(thisValue, args, node) => {
const target = self(thisValue, name, node)
return body(target.items, target, applyCollectionCallback(runner, args[0], `Array.${name}`, node), args, node)
(thisValue, args) => {
const target = self(thisValue, name)
return body(target.items, target, applyCollectionCallback(runner, args[0], `Array.${name}`), args)
},
]
@@ -148,10 +140,10 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
[
"join",
1,
(thisValue, args, node) => {
const target = self(thisValue, "join", node).items
(thisValue, args) => {
const target = self(thisValue, "join").items
if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) {
throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node)
throw typeError("Array.join expects zero arguments or one string separator.")
}
return target.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string))
},
@@ -159,60 +151,56 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
[
"toString",
0,
(thisValue, _, node) =>
self(thisValue, "toString", node)
(thisValue) =>
self(thisValue, "toString")
.items.map((item) => coerceToString(item ?? ""))
.join(","),
],
[
"includes",
1,
(thisValue, args, node) => {
const target = self(thisValue, "includes", node).items
(thisValue, args) => {
const target = self(thisValue, "includes").items
if (args.length === 0 || args.length > 2) {
throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node)
throw typeError("Array.includes expects a value and optional start index.")
}
return target.includes(args[0], optNumber("includes", args[1], "start index", node))
return target.includes(args[0], optNumber("includes", args[1], "start index"))
},
],
[
"indexOf",
1,
(thisValue, args, node) =>
self(thisValue, "indexOf", node).items.indexOf(args[0], optNumber("indexOf", args[1], "start index", node)),
(thisValue, args) =>
self(thisValue, "indexOf").items.indexOf(args[0], optNumber("indexOf", args[1], "start index")),
],
[
"lastIndexOf",
1,
(thisValue, args, node) => {
const target = self(thisValue, "lastIndexOf", node).items
(thisValue, args) => {
const target = self(thisValue, "lastIndexOf").items
return args[1] === undefined
? target.lastIndexOf(args[0])
: target.lastIndexOf(args[0], optNumber("lastIndexOf", args[1], "start index", node))
: target.lastIndexOf(args[0], optNumber("lastIndexOf", args[1], "start index"))
},
],
[
"at",
1,
(thisValue, args, node) => self(thisValue, "at", node).items.at(optNumber("at", args[0], "index", node) ?? 0),
],
["at", 1, (thisValue, args) => self(thisValue, "at").items.at(optNumber("at", args[0], "index") ?? 0)],
[
"slice",
2,
(thisValue, args, node) =>
(thisValue, args) =>
wrap(
self(thisValue, "slice", node).items.slice(
optNumber("slice", args[0], "start", node),
optNumber("slice", args[1], "end", node),
self(thisValue, "slice").items.slice(
optNumber("slice", args[0], "start"),
optNumber("slice", args[1], "end"),
),
),
],
[
"concat",
1,
(thisValue, args, node) =>
(thisValue, args) =>
wrap(
self(thisValue, "concat", node).items.concat(
self(thisValue, "concat").items.concat(
...args.map((item) => (item instanceof ProgramArray ? item.items : item)),
),
),
@@ -220,17 +208,17 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
[
"flat",
0,
(thisValue, args, node) => {
(thisValue, args) => {
const flatten = (items: Array<unknown>, depth: number): Array<unknown> =>
items.flatMap((item) => (item instanceof ProgramArray && depth > 0 ? flatten(item.items, depth - 1) : [item]))
return wrap(flatten(self(thisValue, "flat", node).items, optNumber("flat", args[0], "depth", node) ?? 1))
return wrap(flatten(self(thisValue, "flat").items, optNumber("flat", args[0], "depth") ?? 1))
},
],
[
"reverse",
0,
(thisValue, _, node) => {
const target = self(thisValue, "reverse", node)
(thisValue) => {
const target = self(thisValue, "reverse")
target.items.reverse()
return target
},
@@ -238,13 +226,13 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
[
"sort",
1,
(thisValue, args, node) => {
const target = self(thisValue, "sort", node)
(thisValue, args) => {
const target = self(thisValue, "sort")
const items = target.items
const length = items.length
const holeCount = Array.from({ length }, (_, index) => Object.hasOwn(items, index)).filter((o) => !o).length
const itemCount = length - holeCount
return Effect.map(sortArray(runner, items, args[0], "Array.sort", node), (sorted) => {
return Effect.map(sortArray(runner, items, args[0], "Array.sort"), (sorted) => {
sorted.slice(0, itemCount).forEach((item, index) => {
items[index] = item
})
@@ -258,18 +246,18 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
[
"toSorted",
1,
(thisValue, args, node) =>
Effect.map(sortArray(runner, self(thisValue, "toSorted", node).items, args[0], "Array.toSorted", node), wrap),
(thisValue, args) =>
Effect.map(sortArray(runner, self(thisValue, "toSorted").items, args[0], "Array.toSorted"), wrap),
],
["toReversed", 0, (thisValue, _, node) => wrap([...self(thisValue, "toReversed", node).items].reverse())],
["toReversed", 0, (thisValue) => wrap([...self(thisValue, "toReversed").items].reverse())],
[
"with",
2,
(thisValue, args, node) => {
const target = self(thisValue, "with", node).items
const index = optNumber("with", args[0], "index", node) ?? 0
(thisValue, args) => {
const target = self(thisValue, "with").items
const index = optNumber("with", args[0], "index") ?? 0
const resolved = index < 0 ? target.length + index : index
if (resolved < 0 || resolved >= target.length) throw rangeError("Array.with index is out of range.", node)
if (resolved < 0 || resolved >= target.length) throw rangeError("Array.with index is out of range.")
const copied = [...target]
copied[resolved] = args[1]
return wrap(copied)
@@ -278,80 +266,80 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
[
"push",
1,
(thisValue, args, node) => {
const target = self(thisValue, "push", node)
(thisValue, args) => {
const target = self(thisValue, "push")
// Validate all insertions before mutating to avoid partial cyclic updates.
for (const item of args) rejectCircularInsertion(target, item, "Array.push result", node)
for (const item of args) rejectCircularInsertion(target, item, "Array.push result")
return target.items.push(...args)
},
],
[
"unshift",
1,
(thisValue, args, node) => {
const target = self(thisValue, "unshift", node)
for (const item of args) rejectCircularInsertion(target, item, "Array.unshift result", node)
(thisValue, args) => {
const target = self(thisValue, "unshift")
for (const item of args) rejectCircularInsertion(target, item, "Array.unshift result")
return target.items.unshift(...args)
},
],
["pop", 0, (thisValue, _, node) => self(thisValue, "pop", node).items.pop()],
["shift", 0, (thisValue, _, node) => self(thisValue, "shift", node).items.shift()],
["pop", 0, (thisValue) => self(thisValue, "pop").items.pop()],
["shift", 0, (thisValue) => self(thisValue, "shift").items.shift()],
[
"splice",
2,
(thisValue, args, node) => {
const target = self(thisValue, "splice", node)
(thisValue, args) => {
const target = self(thisValue, "splice")
if (args.length === 0) return wrap(target.items.splice(0, 0))
const start = optNumber("splice", args[0], "start", node) ?? 0
const start = optNumber("splice", args[0], "start") ?? 0
if (args.length === 1) return wrap(target.items.splice(start))
const deleteCount = optNumber("splice", args[1], "delete count", node) ?? 0
const deleteCount = optNumber("splice", args[1], "delete count") ?? 0
const inserted = args.slice(2)
for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result", node)
for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result")
return wrap(target.items.splice(start, deleteCount, ...inserted))
},
],
[
"toSpliced",
2,
(thisValue, args, node) => {
const copied = [...self(thisValue, "toSpliced", node).items]
(thisValue, args) => {
const copied = [...self(thisValue, "toSpliced").items]
if (args.length === 0) return wrap(copied)
const start = optNumber("toSpliced", args[0], "start", node) ?? 0
const start = optNumber("toSpliced", args[0], "start") ?? 0
if (args.length === 1) copied.splice(start)
else copied.splice(start, optNumber("toSpliced", args[1], "delete count", node) ?? 0, ...args.slice(2))
else copied.splice(start, optNumber("toSpliced", args[1], "delete count") ?? 0, ...args.slice(2))
return wrap(copied)
},
],
[
"fill",
1,
(thisValue, args, node) => {
const target = self(thisValue, "fill", node)
rejectCircularInsertion(target, args[0], "Array.fill result", node)
target.items.fill(args[0], optNumber("fill", args[1], "start", node), optNumber("fill", args[2], "end", node))
(thisValue, args) => {
const target = self(thisValue, "fill")
rejectCircularInsertion(target, args[0], "Array.fill result")
target.items.fill(args[0], optNumber("fill", args[1], "start"), optNumber("fill", args[2], "end"))
return target
},
],
[
"copyWithin",
2,
(thisValue, args, node) => {
const target = self(thisValue, "copyWithin", node)
(thisValue, args) => {
const target = self(thisValue, "copyWithin")
target.items.copyWithin(
optNumber("copyWithin", args[0], "target index", node) ?? 0,
optNumber("copyWithin", args[1], "start", node) ?? 0,
optNumber("copyWithin", args[2], "end", node),
optNumber("copyWithin", args[0], "target index") ?? 0,
optNumber("copyWithin", args[1], "start") ?? 0,
optNumber("copyWithin", args[2], "end"),
)
return target
},
],
["keys", 0, (thisValue, _, node) => wrap(Array.from(self(thisValue, "keys", node).items.keys()))],
["values", 0, (thisValue, _, node) => wrap([...self(thisValue, "values", node).items])],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").items.keys()))],
["values", 0, (thisValue) => wrap([...self(thisValue, "values").items])],
[
"entries",
0,
(thisValue, _, node) =>
wrap(Array.from(self(thisValue, "entries", node).items.entries(), ([index, item]) => wrap([index, item]))),
(thisValue) =>
wrap(Array.from(self(thisValue, "entries").items.entries(), ([index, item]) => wrap([index, item]))),
],
iterate("map", 1, (target, receiver, apply) =>
Effect.gen(function* () {
@@ -455,7 +443,7 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
return undefined
}),
),
iterate("reduce", 1, (target, receiver, apply, args, node) =>
iterate("reduce", 1, (target, receiver, apply, args) =>
Effect.gen(function* () {
const length = target.length
let start = 0
@@ -463,7 +451,7 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
if (args.length < 2) {
while (start < length && !(start in target)) start += 1
if (start === length) {
throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node)
throw typeError("Array.reduce of an empty array with no initial value.")
}
accumulator = target[start]
start += 1
@@ -475,14 +463,14 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
return accumulator
}),
),
iterate("reduceRight", 1, (target, receiver, apply, args, node) =>
iterate("reduceRight", 1, (target, receiver, apply, args) =>
Effect.gen(function* () {
let start = target.length - 1
let accumulator = args[1]
if (args.length < 2) {
while (start >= 0 && !(start in target)) start -= 1
if (start < 0) {
throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node)
throw typeError("Array.reduceRight of an empty array with no initial value.")
}
accumulator = target[start]
start -= 1
+55 -69
View File
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { constructor, fn, type Method, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { invalidData, typeError } from "../interpreter/model.js"
import {
define,
defineAccessor,
@@ -24,35 +24,27 @@ import {
toPrimitiveString,
} from "../interpreter/runner.js"
const coerceGroupByPropertyKey = <R>(
runner: Runner<R>,
value: unknown,
node: AstNode,
): Effect.Effect<string, unknown, R> => {
const coerceGroupByPropertyKey = <R>(runner: Runner<R>, value: unknown): Effect.Effect<string, unknown, R> => {
if (value instanceof ProgramPromise) return Effect.succeed("[object Promise]")
if (!isWrapper(value) && isRuntimeReference(value)) {
throw new InterpreterRuntimeError(
`Object.groupBy callback must return a data value, received ${describeValue(value)}.`,
node,
"InvalidDataValue",
)
throw invalidData(`Object.groupBy callback must return a data value, received ${describeValue(value)}.`)
}
return toPrimitiveString(runner, value, node)
return toPrimitiveString(runner, value)
}
/** `Map.groupBy` and `Object.groupBy`: the same iteration, keyed into a Map or a data object. */
export const groupBy = <R>(runner: Runner<R>, namespace: "Map" | "Object") =>
fn<R>(runner.prototypes, "groupBy", 2, (_, args, node) => {
fn<R>(runner.prototypes, "groupBy", 2, (_, args) => {
const protos = runner.prototypes
const source = args[0]
if (source === null || source === undefined) {
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node)
throw typeError(`${namespace}.groupBy expects an iterable collection.`)
}
const apply = applyCollectionCallback(runner, args[1], `${namespace}.groupBy`, node)
const apply = applyCollectionCallback(runner, args[1], `${namespace}.groupBy`)
return Effect.gen(function* () {
const cursor = yield* runner.syncIterator(source, node)
const cursor = yield* runner.syncIterator(source)
if (cursor === undefined) {
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node)
throw typeError(`${namespace}.groupBy expects an iterable collection.`)
}
if (namespace === "Map") {
const result = new ProgramMap(protos.Map)
@@ -78,7 +70,7 @@ export const groupBy = <R>(runner: Runner<R>, namespace: "Map" | "Object") =>
const item = step.value
const key = yield* preserveConsumerError(
cursor,
Effect.flatMap(apply([item, index]), (value) => coerceGroupByPropertyKey(runner, value, node)),
Effect.flatMap(apply([item, index]), (value) => coerceGroupByPropertyKey(runner, value)),
)
const group = getOwn(result, key)
if (group === undefined) define(result, key, new ProgramArray(protos.Array, [item]))
@@ -88,13 +80,13 @@ export const groupBy = <R>(runner: Runner<R>, namespace: "Map" | "Object") =>
})
})
const constructMap = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject, node: AstNode) => {
const constructMap = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject) => {
const target = new ProgramMap(proto)
if (init === undefined || init === null) return Effect.succeed(target)
return Effect.gen(function* () {
const cursor = yield* runner.syncIterator(init, node)
const cursor = yield* runner.syncIterator(init)
if (cursor === undefined) {
throw new InterpreterRuntimeError("new Map(...) expects an iterable of [key, value] pairs or no argument.", node)
throw typeError("new Map(...) expects an iterable of [key, value] pairs or no argument.")
}
while (true) {
const step = yield* cursor.next
@@ -103,7 +95,7 @@ const constructMap = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject,
cursor,
Effect.sync(() => {
if (!(step.value instanceof ProgramObject)) {
throw new InterpreterRuntimeError("new Map(...) expects [key, value] pairs as entry objects.", node)
throw typeError("new Map(...) expects [key, value] pairs as entry objects.")
}
target.map.set(getOwn(step.value, 0), getOwn(step.value, 1))
}),
@@ -112,13 +104,13 @@ const constructMap = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject,
})
}
const constructSet = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject, node: AstNode) => {
const constructSet = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject) => {
const target = new ProgramSet(proto)
if (init === undefined || init === null) return Effect.succeed(target)
return Effect.gen(function* () {
const cursor = yield* runner.syncIterator(init, node)
const cursor = yield* runner.syncIterator(init)
if (cursor === undefined) {
throw new InterpreterRuntimeError("new Set(...) expects a synchronous iterable or no argument.", node)
throw typeError("new Set(...) expects a synchronous iterable or no argument.")
}
while (true) {
const step = yield* cursor.next
@@ -134,48 +126,46 @@ export const mapGlobal = <R>(runner: Runner<R>) => {
const map = constructor<R>(protos, proto, {
name: "Map",
call: requiresNew("Map"),
construct: (args, newTarget, node) => constructMap(runner, args[0], prototypeFrom(newTarget, proto), node),
construct: (args, newTarget) => constructMap(runner, args[0], prototypeFrom(newTarget, proto)),
})
define(map, "groupBy", groupBy(runner, "Map"), hidden)
const self = (thisValue: unknown, name: string, node: AstNode) =>
receiver(ProgramMap, thisValue, `Map.prototype.${name}`, node)
const self = (thisValue: unknown, name: string) => receiver(ProgramMap, thisValue, `Map.prototype.${name}`)
const wrap = (items: Array<unknown>) => new ProgramArray(protos.Array, items)
defineAccessor(proto, "size", (thisValue) => receiver(ProgramMap, thisValue, "Map.prototype.size").map.size)
methods(protos, proto, [
["get", 1, (thisValue, args, node) => self(thisValue, "get", node).map.get(args[0])],
["has", 1, (thisValue, args, node) => self(thisValue, "has", node).map.has(args[0])],
["get", 1, (thisValue, args) => self(thisValue, "get").map.get(args[0])],
["has", 1, (thisValue, args) => self(thisValue, "has").map.has(args[0])],
[
"set",
2,
(thisValue, args, node) => {
const target = self(thisValue, "set", node)
(thisValue, args) => {
const target = self(thisValue, "set")
target.map.set(args[0], args[1])
return target
},
],
["delete", 1, (thisValue, args, node) => self(thisValue, "delete", node).map.delete(args[0])],
["delete", 1, (thisValue, args) => self(thisValue, "delete").map.delete(args[0])],
[
"clear",
0,
(thisValue, _, node) => {
self(thisValue, "clear", node).map.clear()
(thisValue) => {
self(thisValue, "clear").map.clear()
return undefined
},
],
["keys", 0, (thisValue, _, node) => wrap(Array.from(self(thisValue, "keys", node).map.keys()))],
["values", 0, (thisValue, _, node) => wrap(Array.from(self(thisValue, "values", node).map.values()))],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").map.keys()))],
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").map.values()))],
[
"entries",
0,
(thisValue, _, node) =>
wrap(Array.from(self(thisValue, "entries", node).map.entries(), ([key, item]) => wrap([key, item]))),
(thisValue) => wrap(Array.from(self(thisValue, "entries").map.entries(), ([key, item]) => wrap([key, item]))),
],
[
"forEach",
1,
(thisValue, args, node) => {
const target = self(thisValue, "forEach", node)
const apply = applyCollectionCallback(runner, args[0], "Map.forEach", node)
(thisValue, args) => {
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(runner, args[0], "Map.forEach")
return Effect.gen(function* () {
for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target])
return undefined
@@ -196,7 +186,6 @@ const loadSetRecord = <R>(
runner: Runner<R>,
source: unknown,
name: string,
node: AstNode,
): Effect.Effect<SetRecord<R>, unknown, R> => {
if (source instanceof ProgramSet) {
return Effect.succeed({
@@ -213,25 +202,25 @@ const loadSetRecord = <R>(
})
}
if (!(source instanceof ProgramObject)) {
throw new InterpreterRuntimeError(`Set.${name} expects a Set-like object.`, node)
throw typeError(`Set.${name} expects a Set-like object.`)
}
return Effect.gen(function* () {
const size = yield* toPrimitiveNumber(runner, get(source, "size"), node)
const size = yield* toPrimitiveNumber(runner, get(source, "size"))
if (Number.isNaN(size)) {
throw new InterpreterRuntimeError(`Set.${name} received a Set-like object with an invalid size.`, node)
throw typeError(`Set.${name} received a Set-like object with an invalid size.`)
}
const has = get(source, "has")
const keys = get(source, "keys")
if (!isSupportedCallback(has) || !isSupportedCallback(keys)) {
throw new InterpreterRuntimeError(`Set.${name} expects callable 'has' and 'keys' methods.`, node)
throw typeError(`Set.${name} expects callable 'has' and 'keys' methods.`)
}
return {
size: Math.max(Math.trunc(size), 0),
has: (item: unknown) => Effect.map(runner.invokeCallable(has, source, [item], node), Boolean),
has: (item: unknown) => Effect.map(runner.invokeCallable(has, source, [item]), Boolean),
keys: () =>
Effect.flatMap(runner.invokeCallable(keys, source, [], node), (result) => {
Effect.flatMap(runner.invokeCallable(keys, source, []), (result) => {
if (result instanceof ProgramArray) return Effect.succeed(result.items)
throw new InterpreterRuntimeError(`Set.${name} expected 'keys' to return an iterator.`, node)
throw typeError(`Set.${name} expected 'keys' to return an iterator.`)
}),
}
})
@@ -242,10 +231,9 @@ const setOperation = <R>(
target: ProgramSet,
name: string,
source: unknown,
node: AstNode,
): Effect.Effect<unknown, unknown, R> =>
Effect.gen(function* () {
const other = yield* loadSetRecord(runner, source, name, node)
const other = yield* loadSetRecord(runner, source, name)
const copy = () => {
const result = new ProgramSet(runner.prototypes.Set)
for (const item of target.set.values()) result.set.add(item)
@@ -320,51 +308,49 @@ export const setGlobal = <R>(runner: Runner<R>) => {
const set = constructor<R>(protos, proto, {
name: "Set",
call: requiresNew("Set"),
construct: (args, newTarget, node) => constructSet(runner, args[0], prototypeFrom(newTarget, proto), node),
construct: (args, newTarget) => constructSet(runner, args[0], prototypeFrom(newTarget, proto)),
})
const self = (thisValue: unknown, name: string, node: AstNode) =>
receiver(ProgramSet, thisValue, `Set.prototype.${name}`, node)
const self = (thisValue: unknown, name: string) => receiver(ProgramSet, thisValue, `Set.prototype.${name}`)
const wrap = (items: Array<unknown>) => new ProgramArray(protos.Array, items)
const operation = (name: string): Method => [
name,
1,
(thisValue, args, node) => setOperation(runner, self(thisValue, name, node), name, args[0], node),
(thisValue, args) => setOperation(runner, self(thisValue, name), name, args[0]),
]
defineAccessor(proto, "size", (thisValue) => receiver(ProgramSet, thisValue, "Set.prototype.size").set.size)
methods(protos, proto, [
["has", 1, (thisValue, args, node) => self(thisValue, "has", node).set.has(args[0])],
["has", 1, (thisValue, args) => self(thisValue, "has").set.has(args[0])],
[
"add",
1,
(thisValue, args, node) => {
const target = self(thisValue, "add", node)
(thisValue, args) => {
const target = self(thisValue, "add")
target.set.add(args[0])
return target
},
],
["delete", 1, (thisValue, args, node) => self(thisValue, "delete", node).set.delete(args[0])],
["delete", 1, (thisValue, args) => self(thisValue, "delete").set.delete(args[0])],
[
"clear",
0,
(thisValue, _, node) => {
self(thisValue, "clear", node).set.clear()
(thisValue) => {
self(thisValue, "clear").set.clear()
return undefined
},
],
["keys", 0, (thisValue, _, node) => wrap(Array.from(self(thisValue, "keys", node).set.values()))],
["values", 0, (thisValue, _, node) => wrap(Array.from(self(thisValue, "values", node).set.values()))],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").set.values()))],
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").set.values()))],
[
"entries",
0,
(thisValue, _, node) =>
wrap(Array.from(self(thisValue, "entries", node).set.values(), (item) => wrap([item, item]))),
(thisValue) => wrap(Array.from(self(thisValue, "entries").set.values(), (item) => wrap([item, item]))),
],
[
"forEach",
1,
(thisValue, args, node) => {
const target = self(thisValue, "forEach", node)
const apply = applyCollectionCallback(runner, args[0], "Set.forEach", node)
(thisValue, args) => {
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(runner, args[0], "Set.forEach")
return Effect.gen(function* () {
for (const item of Array.from(target.set.values())) yield* apply([item, item, target])
return undefined
+22 -25
View File
@@ -1,16 +1,16 @@
import { Effect } from "effect"
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
import { type AstNode, rangeError } from "../interpreter/model.js"
import { rangeError } from "../interpreter/model.js"
import { ProgramDate, ProgramObject } from "../interpreter/objects.js"
import { type Runner, toPrimitive, toPrimitiveNumber } from "../interpreter/runner.js"
import { coerceToNumber, coerceToString } from "./value.js"
const constructDate = <R>(runner: Runner<R>, args: Array<unknown>, proto: ProgramObject, node: AstNode) => {
const constructDate = <R>(runner: Runner<R>, args: Array<unknown>, proto: ProgramObject) => {
if (args.length === 0) return Effect.succeed(new ProgramDate(proto, Date.now()))
if (args.length === 1) {
const arg = args[0]
if (arg instanceof ProgramDate) return Effect.succeed(new ProgramDate(proto, arg.time))
return Effect.map(toPrimitive(runner, arg, "default", node), (value) =>
return Effect.map(toPrimitive(runner, arg, "default"), (value) =>
typeof value === "string"
? new ProgramDate(proto, Date.parse(value))
: new ProgramDate(proto, new Date(coerceToNumber(value)).getTime()),
@@ -74,7 +74,7 @@ export const dateGlobal = <R>(runner: Runner<R>) => {
length: 7,
// ISO instead of the host's locale string: date strings are deterministic and must not leak the host timezone.
call: () => Effect.sync(() => new Date().toISOString()),
construct: (args, newTarget, node) => constructDate(runner, args, prototypeFrom(newTarget, proto), node),
construct: (args, newTarget) => constructDate(runner, args, prototypeFrom(newTarget, proto)),
})
methods(protos, date, [
["now", 0, () => Date.now()],
@@ -82,42 +82,39 @@ export const dateGlobal = <R>(runner: Runner<R>) => {
["UTC", 7, (_, args) => Date.UTC(...(args.map((arg) => coerceToNumber(arg)) as Parameters<typeof Date.UTC>))],
])
const self = (thisValue: unknown, name: string, node: AstNode) =>
receiver(ProgramDate, thisValue, `Date.prototype.${name}`, node)
const iso = (value: ProgramDate, node: AstNode) => {
if (!Number.isFinite(value.time)) throw rangeError("Invalid time value.", node)
const self = (thisValue: unknown, name: string) => receiver(ProgramDate, thisValue, `Date.prototype.${name}`)
const iso = (value: ProgramDate) => {
if (!Number.isFinite(value.time)) throw rangeError("Invalid time value.")
return new Date(value.time).toISOString()
}
methods(protos, proto, [
["getTime", 0, (thisValue, _, node) => self(thisValue, "getTime", node).time],
["valueOf", 0, (thisValue, _, node) => self(thisValue, "valueOf", node).time],
["toISOString", 0, (thisValue, _, node) => iso(self(thisValue, "toISOString", node), node)],
["getTime", 0, (thisValue) => self(thisValue, "getTime").time],
["valueOf", 0, (thisValue) => self(thisValue, "valueOf").time],
["toISOString", 0, (thisValue) => iso(self(thisValue, "toISOString"))],
[
"toJSON",
1,
(thisValue, _, node) => {
const value = self(thisValue, "toJSON", node)
return Number.isFinite(value.time) ? iso(value, node) : null
(thisValue) => {
const value = self(thisValue, "toJSON")
return Number.isFinite(value.time) ? iso(value) : null
},
],
["toString", 0, (thisValue, _, node) => coerceToString(self(thisValue, "toString", node))],
["toDateString", 0, (thisValue, _, node) => new Date(self(thisValue, "toDateString", node).time).toDateString()],
["toTimeString", 0, (thisValue, _, node) => new Date(self(thisValue, "toTimeString", node).time).toTimeString()],
["toUTCString", 0, (thisValue, _, node) => new Date(self(thisValue, "toUTCString", node).time).toUTCString()],
["toGMTString", 0, (thisValue, _, node) => new Date(self(thisValue, "toGMTString", node).time).toUTCString()],
...getters.map(
(name): Method => [name, 0, (thisValue, _, node) => new Date(self(thisValue, name, node).time)[name]()],
),
["toString", 0, (thisValue) => coerceToString(self(thisValue, "toString"))],
["toDateString", 0, (thisValue) => new Date(self(thisValue, "toDateString").time).toDateString()],
["toTimeString", 0, (thisValue) => new Date(self(thisValue, "toTimeString").time).toTimeString()],
["toUTCString", 0, (thisValue) => new Date(self(thisValue, "toUTCString").time).toUTCString()],
["toGMTString", 0, (thisValue) => new Date(self(thisValue, "toGMTString").time).toUTCString()],
...getters.map((name): Method => [name, 0, (thisValue) => new Date(self(thisValue, name).time)[name]()]),
...setters.map(
([name, length]): Method => [
name,
length,
(thisValue, args, node) => {
const target = self(thisValue, name, node)
(thisValue, args) => {
const target = self(thisValue, name)
// Native setters read the current time before argument coercion, whose callbacks may mutate the Date.
const hosted = new Date(target.time)
return Effect.map(
Effect.forEach(args.slice(0, length), (arg) => toPrimitiveNumber(runner, arg, node), {
Effect.forEach(args.slice(0, length), (arg) => toPrimitiveNumber(runner, arg), {
concurrency: 1,
}),
(values) => {
+15 -17
View File
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import { methods } from "../interpreter/native.js"
import { applyCollectionCallback, type Runner } from "../interpreter/runner.js"
import { type AstNode, InterpreterRuntimeError, syntaxError } from "../interpreter/model.js"
import { syntaxError, typeError } from "../interpreter/model.js"
import { typeofValue } from "../interpreter/references.js"
import { fromData, toData, toProgram } from "../data.js"
import { Callable, get, keys, ProgramArray, ProgramObject, record, remove, set } from "../interpreter/objects.js"
@@ -9,29 +9,26 @@ import { Callable, get, keys, ProgramArray, ProgramObject, record, remove, set }
export const jsonGlobal = <R>(runner: Runner<R>) => {
const json = new ProgramObject(runner.prototypes.Object)
methods(runner.prototypes, json, [
["parse", 2, (_, args, node) => parse(runner, args, node)],
["stringify", 3, (_, args, node) => stringify(runner, args, node)],
["parse", 2, (_, args) => parse(runner, args)],
["stringify", 3, (_, args) => stringify(runner, args)],
])
return json
}
const parse = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> => {
const parse = <R>(runner: Runner<R>, args: Array<unknown>): Effect.Effect<unknown, unknown, R> => {
const text = args[0]
if (typeof text !== "string") throw new InterpreterRuntimeError("JSON.parse expects a string.", node)
if (typeof text !== "string") throw typeError("JSON.parse expects a string.")
const parsed = (() => {
try {
return fromData(runner.prototypes, JSON.parse(text), "JSON.parse result")
} catch (error) {
throw syntaxError(
`JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
node,
)
throw syntaxError(`JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`)
}
})()
if (typeofValue(args[1]) !== "function") return Effect.succeed(parsed)
const apply = applyCollectionCallback(runner, args[1], "JSON.parse", node)
const apply = applyCollectionCallback(runner, args[1], "JSON.parse")
const visit = (holder: ProgramObject, key: string): Effect.Effect<unknown, unknown, R> =>
Effect.gen(function* () {
const value = get(holder, key)
@@ -47,7 +44,7 @@ const parse = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): Effec
return visit(record(runner.prototypes.Object, { "": parsed }), "")
}
const stringify = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> => {
const stringify = <R>(runner: Runner<R>, args: Array<unknown>): Effect.Effect<unknown, unknown, R> => {
const space = args[2]
const indent = typeof space === "number" || typeof space === "string" ? space : undefined
const replacer = args[1]
@@ -59,22 +56,23 @@ const stringify = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): E
.filter((item): item is string | number => typeof item === "string" || typeof item === "number")
.map(String)
: null
return Effect.succeed(JSON.stringify(toData(args[0], "JSON.stringify value"), properties, indent))
// A string cannot pollute, so __proto__ stays: JSON.stringify includes own __proto__ keys, like JS.
return Effect.succeed(JSON.stringify(toData(args[0], "JSON.stringify value", "json", false), properties, indent))
}
// Validate up front; the replacer walk below reads the original value.
toProgram(runner.prototypes, args[0], "JSON.stringify value")
const apply = applyCollectionCallback(runner, replacer, "JSON.stringify", node)
const apply = applyCollectionCallback(runner, replacer, "JSON.stringify")
const stack = new Set<object>()
const visit = (holder: ProgramObject, key: string): Effect.Effect<unknown, unknown, R> =>
Effect.gen(function* () {
const value = yield* apply([key, yield* toJSONValue(runner, get(holder, key), key, node)])
const value = yield* apply([key, yield* toJSONValue(runner, get(holder, key), key)])
if (value === undefined || typeofValue(value) === "function") return undefined
toProgram(runner.prototypes, value, "JSON.stringify replacer result")
if (typeof value === "number") return Number.isFinite(value) ? value : null
if (value === null || typeof value === "string" || typeof value === "boolean") return value
if (!(value instanceof ProgramObject)) return {}
if (stack.has(value)) throw new InterpreterRuntimeError("Converting circular structure to JSON.", node)
if (stack.has(value)) throw typeError("Converting circular structure to JSON.")
stack.add(value)
if (value instanceof ProgramArray) {
const result: Array<unknown> = []
@@ -99,8 +97,8 @@ const stringify = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): E
}
// SerializeJSONProperty step 2: a callable `toJSON` decides the value, as Date and URL define.
const toJSONValue = <R>(runner: Runner<R>, value: unknown, key: string, node: AstNode) => {
const toJSONValue = <R>(runner: Runner<R>, value: unknown, key: string) => {
if (!(value instanceof ProgramObject)) return Effect.succeed(value)
const toJSON = get(value, "toJSON")
return toJSON instanceof Callable ? runner.invokeCallable(toJSON, value, [key], node) : Effect.succeed(value)
return toJSON instanceof Callable ? runner.invokeCallable(toJSON, value, [key]) : Effect.succeed(value)
}
+11 -15
View File
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { constants, type Method, methods } from "../interpreter/native.js"
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { typeError } from "../interpreter/model.js"
import { ProgramObject } from "../interpreter/objects.js"
import { preserveConsumerError, type Runner } from "../interpreter/runner.js"
@@ -13,32 +13,28 @@ declare global {
// Validate only the arguments a method consumes; like JS, extras are ignored
// (so built-ins work as callbacks receiving (element, index, array)).
const number = (name: string, args: Array<unknown>, index: number, node: AstNode): number => {
const number = (name: string, args: Array<unknown>, index: number): number => {
if (index >= args.length) return Number.NaN
const arg = args[index]
if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node)
if (typeof arg !== "number") throw typeError(`Math.${name} expects number arguments.`)
return arg
}
const unary = (name: string, op: (a: number) => number): Method => [
name,
1,
(_, args, node) => op(number(name, args, 0, node)),
]
const unary = (name: string, op: (a: number) => number): Method => [name, 1, (_, args) => op(number(name, args, 0))]
const binary = (name: string, op: (a: number, b: number) => number): Method => [
name,
2,
(_, args, node) => op(number(name, args, 0, node), number(name, args, 1, node)),
(_, args) => op(number(name, args, 0), number(name, args, 1)),
]
const variadic = (name: string, op: (...values: Array<number>) => number): Method => [
name,
2,
(_, args, node) =>
(_, args) =>
op(
...args.map((arg) => {
if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node)
if (typeof arg !== "number") throw typeError(`Math.${name} expects number arguments.`)
return arg
}),
),
@@ -97,11 +93,11 @@ export const mathGlobal = <R>(runner: Runner<R>) => {
[
"sumPrecise",
1,
(_, args, node) =>
(_, args) =>
Effect.gen(function* () {
const cursor = yield* runner.syncIterator(args[0], node)
const cursor = yield* runner.syncIterator(args[0])
if (cursor === undefined) {
throw new InterpreterRuntimeError("Math.sumPrecise expects a synchronous iterable.", node)
throw typeError("Math.sumPrecise expects a synchronous iterable.")
}
const numbers: Array<number> = []
while (true) {
@@ -111,7 +107,7 @@ export const mathGlobal = <R>(runner: Runner<R>) => {
cursor,
Effect.sync(() => {
if (typeof step.value !== "number") {
throw new InterpreterRuntimeError("Math.sumPrecise expects an iterable of numbers.", node)
throw typeError("Math.sumPrecise expects an iterable of numbers.")
}
numbers.push(step.value)
}),
+21 -26
View File
@@ -1,5 +1,5 @@
import { constructor, constants, methods } from "../interpreter/native.js"
import { type AstNode, InterpreterRuntimeError, rangeError } from "../interpreter/model.js"
import { rangeError, typeError } from "../interpreter/model.js"
import type { Runner } from "../interpreter/runner.js"
import { coercion, coerceToString } from "./value.js"
@@ -28,10 +28,10 @@ export const numberGlobal = <R>(runner: Runner<R>) => {
[
"parseInt",
2,
(_, args, node) => {
(_, args) => {
const radix = args[1]
if (radix !== undefined && typeof radix !== "number") {
throw new InterpreterRuntimeError("Number.parseInt expects a numeric radix.", node)
throw typeError("Number.parseInt expects a numeric radix.")
}
return parseInt(coerceToString(args[0]), radix)
},
@@ -39,49 +39,44 @@ export const numberGlobal = <R>(runner: Runner<R>) => {
["parseFloat", 1, (_, args) => parseFloat(coerceToString(args[0]))],
])
const self = (thisValue: unknown, name: string, node: AstNode): number => {
const self = (thisValue: unknown, name: string): number => {
if (typeof thisValue === "number") return thisValue
throw new InterpreterRuntimeError(`Number.prototype.${name} requires that 'this' be a Number.`, node)
throw typeError(`Number.prototype.${name} requires that 'this' be a Number.`)
}
const optNum = (name: string, arg: unknown, node: AstNode): number | undefined => {
const optNum = (name: string, arg: unknown): number | undefined => {
if (arg === undefined) return undefined
if (typeof arg !== "number") throw new InterpreterRuntimeError(`Number.${name} expects a number argument.`, node)
if (typeof arg !== "number") throw typeError(`Number.${name} expects a number argument.`)
return arg
}
methods(protos, protos.Number, [
[
"toFixed",
1,
(thisValue, args, node) => self(thisValue, "toFixed", node).toFixed(optNum("toFixed", args[0], node)),
],
["toFixed", 1, (thisValue, args) => self(thisValue, "toFixed").toFixed(optNum("toFixed", args[0]))],
[
"toExponential",
1,
(thisValue, args, node) =>
self(thisValue, "toExponential", node).toExponential(optNum("toExponential", args[0], node)),
(thisValue, args) => self(thisValue, "toExponential").toExponential(optNum("toExponential", args[0])),
],
[
"toPrecision",
1,
(thisValue, args, node) => {
const value = self(thisValue, "toPrecision", node)
const digits = optNum("toPrecision", args[0], node)
(thisValue, args) => {
const value = self(thisValue, "toPrecision")
const digits = optNum("toPrecision", args[0])
return digits === undefined ? value.toString() : value.toPrecision(digits)
},
],
[
"toString",
1,
(thisValue, args, node) => {
const value = self(thisValue, "toString", node)
const radix = optNum("toString", args[0], node)
(thisValue, args) => {
const value = self(thisValue, "toString")
const radix = optNum("toString", args[0])
if (radix !== undefined && (radix < 2 || radix > 36)) {
throw rangeError("Number.toString radix must be between 2 and 36.", node)
throw rangeError("Number.toString radix must be between 2 and 36.")
}
return value.toString(radix)
},
],
["valueOf", 0, (thisValue, _, node) => self(thisValue, "valueOf", node)],
["valueOf", 0, (thisValue) => self(thisValue, "valueOf")],
])
return number
}
@@ -93,13 +88,13 @@ export const booleanGlobal = <R>(runner: Runner<R>) => {
length: 1,
call: coercion(runner, "Boolean").call,
})
const self = (thisValue: unknown, name: string, node: AstNode): boolean => {
const self = (thisValue: unknown, name: string): boolean => {
if (typeof thisValue === "boolean") return thisValue
throw new InterpreterRuntimeError(`Boolean.prototype.${name} requires that 'this' be a Boolean.`, node)
throw typeError(`Boolean.prototype.${name} requires that 'this' be a Boolean.`)
}
methods(protos, protos.Boolean, [
["toString", 0, (thisValue, _, node) => String(self(thisValue, "toString", node))],
["valueOf", 0, (thisValue, _, node) => self(thisValue, "valueOf", node)],
["toString", 0, (thisValue) => String(self(thisValue, "toString"))],
["valueOf", 0, (thisValue) => self(thisValue, "valueOf")],
])
return boolean
}
+44 -58
View File
@@ -4,9 +4,10 @@ import { constructor, methods, receiver } from "../interpreter/native.js"
import {
type AstNode,
AsyncIteratorSymbol,
InterpreterRuntimeError,
invalidData,
IteratorSymbol,
rangeError,
typeError,
} from "../interpreter/model.js"
import {
Callable,
@@ -34,22 +35,22 @@ import { groupBy } from "./collections.js"
import { coerceToString } from "./value.js"
// ToObject for enumeration.
export const enumerableSource = <R>(runner: Runner<R>, label: string, value: unknown, node: AstNode): ProgramObject => {
export const enumerableSource = <R>(
runner: Runner<R>,
label: string,
value: unknown,
node?: AstNode,
): ProgramObject => {
if (value === null || value === undefined) {
throw new InterpreterRuntimeError(`${label} cannot convert ${describeValue(value)} to an object.`, node)
throw typeError(`${label} cannot convert ${describeValue(value)} to an object.`, node)
}
if (value instanceof ProgramPromise) {
throw new InterpreterRuntimeError(
`${label} received an un-awaited Promise; await it before inspecting the result.`,
node,
"InvalidDataValue",
)
throw invalidData(`${label} received an un-awaited Promise; await it before inspecting the result.`, node)
}
if (value instanceof ToolReference) {
throw new InterpreterRuntimeError(
throw invalidData(
`${label} cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or search({ query }) for signatures.`,
node,
"InvalidDataValue",
)
}
if (typeof value === "string") return new ProgramArray(runner.prototypes.Array, [...value])
@@ -57,40 +58,33 @@ export const enumerableSource = <R>(runner: Runner<R>, label: string, value: unk
return new ProgramObject(runner.prototypes.Object)
}
export const objectAssign = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): unknown => {
export const objectAssign = <R>(runner: Runner<R>, args: Array<unknown>): unknown => {
const target = args[0]
// JS would box a primitive target; wrappers and primitives cannot hold fields here.
if (!(target instanceof ProgramObject)) {
throw new InterpreterRuntimeError(
`Object.assign expects a data object or array target, received ${describeValue(target)}.`,
node,
)
throw typeError(`Object.assign expects a data object or array target, received ${describeValue(target)}.`)
}
const seen = new Set<object>()
for (const source of args.slice(1)) {
if (source === null || source === undefined) continue
const from = enumerableSource(runner, "Object.assign(...)", source, node)
const from = enumerableSource(runner, "Object.assign(...)", source)
for (const key of enumerableKeys(from)) {
rejectCircularInsertion(target, getOwn(from, key), "Object.assign result", node, seen)
rejectCircularInsertion(target, getOwn(from, key), "Object.assign result", seen)
if (!set(target, key, getOwn(from, key))) {
if (target instanceof ProgramArray && key === "length") throw rangeError("Invalid array length", node)
throw new InterpreterRuntimeError(`Cannot assign to read only property '${String(key)}'.`, node)
if (target instanceof ProgramArray && key === "length") throw rangeError("Invalid array length")
throw typeError(`Cannot assign to read only property '${String(key)}'.`)
}
}
}
return target
}
const objectFromEntries = <R>(
runner: Runner<R>,
source: unknown,
node: AstNode,
): Effect.Effect<ProgramObject, unknown, R> => {
const objectFromEntries = <R>(runner: Runner<R>, source: unknown): Effect.Effect<ProgramObject, unknown, R> => {
const out = new ProgramObject(runner.prototypes.Object)
return Effect.gen(function* () {
const cursor = yield* runner.syncIterator(source, node)
const cursor = yield* runner.syncIterator(source)
if (cursor === undefined) {
throw new InterpreterRuntimeError("Object.fromEntries expects a synchronous iterable of entries.", node)
throw typeError("Object.fromEntries expects a synchronous iterable of entries.")
}
while (true) {
const step = yield* cursor.next
@@ -99,7 +93,7 @@ const objectFromEntries = <R>(
cursor,
Effect.sync(() => {
if (!(step.value instanceof ProgramObject) || containsOpaqueReference(step.value)) {
throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node)
throw typeError("Object.fromEntries expects [key, value] entry objects.")
}
define(out, coerceToString(getOwn(step.value, 0)), getOwn(step.value, 1))
}),
@@ -132,91 +126,83 @@ export const objectGlobal = <R>(
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
) => {
const protos = runner.prototypes
const construct = (args: Array<unknown>, node: AstNode): unknown => {
const construct = (args: Array<unknown>): unknown => {
const first = args[0]
if (first === null || first === undefined) return new ProgramObject(protos.Object)
if (typeof first === "object") return first
throw new InterpreterRuntimeError(
`Object(${typeof first}) wrapper objects are not supported; use the primitive value directly.`,
node,
)
if (first instanceof ProgramObject) return first
throw typeError(`Object(${typeof first}) wrapper objects are not supported; use the primitive value directly.`)
}
const object = constructor<R>(protos, protos.Object, {
name: "Object",
length: 1,
call: (_, args, node) => Effect.sync(() => construct(args, node)),
construct: (args, _, node) => Effect.sync(() => construct(args, node)),
call: (_, args) => Effect.sync(() => construct(args)),
construct: (args) => Effect.sync(() => construct(args)),
})
methods(protos, object, [
[
"keys",
1,
(_, args, node) =>
(_, args) =>
toProgram(
protos,
args[0] instanceof ToolReference
? [...toolKeys(args[0].path)]
: keys(enumerableSource(runner, "Object.keys(...)", args[0], node)),
: keys(enumerableSource(runner, "Object.keys(...)", args[0])),
"Object.keys result",
),
],
[
"values",
1,
(_, args, node) =>
(_, args) =>
new ProgramArray(
protos.Array,
entries(enumerableSource(runner, "Object.values(...)", args[0], node)).map((entry) => entry[1]),
entries(enumerableSource(runner, "Object.values(...)", args[0])).map((entry) => entry[1]),
),
],
[
"entries",
1,
(_, args, node) =>
(_, args) =>
new ProgramArray(
protos.Array,
entries(enumerableSource(runner, "Object.entries(...)", args[0], node)).map(
entries(enumerableSource(runner, "Object.entries(...)", args[0])).map(
(entry) => new ProgramArray(protos.Array, entry),
),
),
],
[
"hasOwn",
2,
(_, args, node) => hasOwn(enumerableSource(runner, "Object.hasOwn(...)", args[0], node), propertyKey(args[1])),
],
["hasOwn", 2, (_, args) => hasOwn(enumerableSource(runner, "Object.hasOwn(...)", args[0]), propertyKey(args[1]))],
[
"is",
2,
(_, args, node) => {
(_, args) => {
if (containsOpaqueReference(args[0]) || containsOpaqueReference(args[1])) {
throw new InterpreterRuntimeError("Object.is requires data values.", node, "InvalidDataValue")
throw invalidData("Object.is requires data values.")
}
return Object.is(args[0], args[1])
},
],
["assign", 2, (_, args, node) => objectAssign(runner, args, node)],
["fromEntries", 1, (_, args, node) => objectFromEntries(runner, args[0], node)],
["assign", 2, (_, args) => objectAssign(runner, args)],
["fromEntries", 1, (_, args) => objectFromEntries(runner, args[0])],
])
define(object, "groupBy", groupBy(runner, "Object"), hidden)
methods(protos, protos.Object, [
[
"hasOwnProperty",
1,
(thisValue, args, node) =>
hasOwn(receiver(ProgramObject, thisValue, "Object.prototype.hasOwnProperty", node), propertyKey(args[0])),
(thisValue, args) =>
hasOwn(receiver(ProgramObject, thisValue, "Object.prototype.hasOwnProperty"), propertyKey(args[0])),
],
[
"isPrototypeOf",
1,
(thisValue, args, node) =>
hasPrototype(args[0], receiver(ProgramObject, thisValue, "Object.prototype.isPrototypeOf", node)),
(thisValue, args) => hasPrototype(args[0], receiver(ProgramObject, thisValue, "Object.prototype.isPrototypeOf")),
],
[
"propertyIsEnumerable",
1,
(thisValue, args, node) =>
own(receiver(ProgramObject, thisValue, "Object.prototype.propertyIsEnumerable", node), propertyKey(args[0]))
(thisValue, args) =>
own(receiver(ProgramObject, thisValue, "Object.prototype.propertyIsEnumerable"), propertyKey(args[0]))
?.enumerable === true,
],
["toString", 0, (thisValue) => `[object ${classTag(thisValue)}]`],
@@ -224,9 +210,9 @@ export const objectGlobal = <R>(
[
"valueOf",
0,
(thisValue, _, node) => {
(thisValue) => {
if (thisValue === null || thisValue === undefined) {
throw new InterpreterRuntimeError("Object.prototype.valueOf called on null or undefined.", node)
throw typeError("Object.prototype.valueOf called on null or undefined.")
}
return thisValue
},
+11 -18
View File
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { Prototypes } from "../interpreter/intrinsics.js"
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
import { type AstNode, InterpreterRuntimeError, syntaxError } from "../interpreter/model.js"
import { syntaxError, typeError } from "../interpreter/model.js"
import {
define,
defineAccessor,
@@ -32,7 +32,7 @@ const regexFailureReason = (error: unknown): string =>
const escapeRegexHint =
'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.'
export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => {
export const toHostRegex = (arg: unknown, method: string, extraFlags = ""): RegExp => {
// Native parity: an undefined pattern behaves as an empty pattern.
if (arg === undefined) return new RegExp("", extraFlags)
if (arg instanceof ProgramRegExp) return arg.regex
@@ -42,13 +42,11 @@ export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFl
} catch (error) {
throw syntaxError(
`String.${method} received the string ${JSON.stringify(arg)}, which is not a valid regular expression pattern (${regexFailureReason(error)}). ${escapeRegexHint}`,
node,
)
}
}
throw new InterpreterRuntimeError(
throw typeError(
`String.${method} expects a regular expression (a /pattern/flags literal or new RegExp(...)) or a string pattern, not ${arg === null ? "null" : typeof arg}.`,
node,
)
}
@@ -67,7 +65,6 @@ export const matchToValue = (protos: Prototypes, match: RegExpMatchArray): Progr
export const constructRegExp = (
protos: Prototypes,
args: Array<unknown>,
node: AstNode,
proto: ProgramObject = protos.RegExp,
): ProgramRegExp => {
const first = args[0]
@@ -76,7 +73,6 @@ export const constructRegExp = (
if (flagsArg !== undefined && typeof flagsArg !== "string") {
throw syntaxError(
`RegExp flags must be a string of flag characters (e.g. "g", "gi"), not ${flagsArg === null ? "null" : typeof flagsArg}.`,
node,
)
}
const flags = flagsArg ?? (first instanceof ProgramRegExp ? first.regex.flags : "")
@@ -88,7 +84,6 @@ export const constructRegExp = (
/flag/i.test(reason)
? `new RegExp(...) received invalid flags ${JSON.stringify(flags)} (${reason}). Valid flags are d, g, i, m, s, u, v, and y.`
: `new RegExp(...) received ${JSON.stringify(pattern)}, which is not a valid regular expression pattern (${reason}). ${escapeRegexHint}`,
node,
)
}
}
@@ -106,23 +101,21 @@ export const regexpGlobal = <R>(runner: Runner<R>) => {
const regexp = constructor<R>(protos, proto, {
name: "RegExp",
length: 2,
call: (_, args, node) => Effect.sync(() => constructRegExp(protos, args, node)),
construct: (args, newTarget, node) =>
Effect.sync(() => constructRegExp(protos, args, node, prototypeFrom(newTarget, proto))),
call: (_, args) => Effect.sync(() => constructRegExp(protos, args)),
construct: (args, newTarget) => Effect.sync(() => constructRegExp(protos, args, prototypeFrom(newTarget, proto))),
})
methods(protos, regexp, [
[
"escape",
1,
(_, args, node) => {
if (typeof args[0] !== "string") throw new InterpreterRuntimeError("RegExp.escape expects a string.", node)
(_, args) => {
if (typeof args[0] !== "string") throw typeError("RegExp.escape expects a string.")
return RegExp.escape(args[0])
},
],
])
const self = (thisValue: unknown, name: string, node?: AstNode) =>
receiver(ProgramRegExp, thisValue, `RegExp.prototype.${name}`, node)
const self = (thisValue: unknown, name: string) => receiver(ProgramRegExp, thisValue, `RegExp.prototype.${name}`)
defineAccessor(proto, "source", (thisValue) => self(thisValue, "source").regex.source)
defineAccessor(proto, "flags", (thisValue) => self(thisValue, "flags").regex.flags)
for (const name of flagProperties) defineAccessor(proto, name, (thisValue) => self(thisValue, name).regex[name])
@@ -130,8 +123,8 @@ export const regexpGlobal = <R>(runner: Runner<R>) => {
const run = (name: "exec" | "test"): Method => [
name,
1,
(thisValue, args, node) => {
const value = self(thisValue, name, node)
(thisValue, args) => {
const value = self(thisValue, name)
const input = coerceToString(args[0])
const stateful = value.regex.global || value.regex.sticky
value.regex.lastIndex = toLength(getOwn(value, "lastIndex"))
@@ -144,7 +137,7 @@ export const regexpGlobal = <R>(runner: Runner<R>) => {
methods(protos, proto, [
run("exec"),
run("test"),
["toString", 0, (thisValue, _, node) => coerceToString(self(thisValue, "toString", node))],
["toString", 0, (thisValue) => coerceToString(self(thisValue, "toString"))],
])
return regexp
}
+73 -97
View File
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import { toProgram } from "../data.js"
import { constructor, type Method, methods } from "../interpreter/native.js"
import { type AstNode, InterpreterRuntimeError, rangeError } from "../interpreter/model.js"
import { invalidData, rangeError, typeError } from "../interpreter/model.js"
import { ProgramArray, ProgramPromise, ProgramRegExp, record } from "../interpreter/objects.js"
import { containsOpaqueReference, typeofValue } from "../interpreter/references.js"
import { applyCollectionCallback, isSupportedCallback, type Runner } from "../interpreter/runner.js"
@@ -9,22 +9,17 @@ import { matchToValue, toHostRegex } from "./regexp.js"
import { coerceToNumber, coerceToString, coercion } from "./value.js"
// console is intercepted by the interpreter before reaching here.
const requireDataArgument = (name: string, index: number, arg: unknown, node: AstNode): unknown => {
const requireDataArgument = (name: string, index: number, arg: unknown): unknown => {
if (containsOpaqueReference(arg)) {
throw new InterpreterRuntimeError(
`String.${name} expects argument ${index + 1} to be a data value.`,
node,
"InvalidDataValue",
)
throw invalidData(`String.${name} expects argument ${index + 1} to be a data value.`)
}
return arg
}
const replaceAllNeedsGlobal = (pattern: RegExp, node: AstNode) => {
const replaceAllNeedsGlobal = (pattern: RegExp) => {
if (!pattern.global) {
throw new InterpreterRuntimeError(
throw typeError(
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.replace to replace only the first match.`,
node,
)
}
}
@@ -34,10 +29,9 @@ const replaceWithCallback = <R>(
value: string,
name: "replace" | "replaceAll",
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
const protos = runner.prototypes
const apply = applyCollectionCallback(runner, args[1], `String.${name}`, node)
const apply = applyCollectionCallback(runner, args[1], `String.${name}`)
const matches: Array<{ readonly match: string; readonly offset: number; readonly args: Array<unknown> }> = []
const collect = (...callbackArgs: Array<unknown>): string => {
const match = callbackArgs[0]
@@ -45,7 +39,7 @@ const replaceWithCallback = <R>(
const hasGroups = groups !== null && typeof groups === "object"
const offset = callbackArgs[callbackArgs.length - (hasGroups ? 3 : 2)]
if (typeof match !== "string" || typeof offset !== "number") {
throw new InterpreterRuntimeError(`String.${name} produced an invalid replacement match.`, node)
throw typeError(`String.${name} produced an invalid replacement match.`)
}
if (hasGroups) callbackArgs[callbackArgs.length - 1] = record(protos.Object, groups as Record<string, unknown>)
matches.push({ match, offset, args: callbackArgs })
@@ -54,11 +48,11 @@ const replaceWithCallback = <R>(
const pattern = args[0]
if (pattern instanceof ProgramRegExp) {
if (name === "replaceAll") replaceAllNeedsGlobal(pattern.regex, node)
if (name === "replaceAll") replaceAllNeedsGlobal(pattern.regex)
if (name === "replace") value.replace(pattern.regex, collect)
else value.replaceAll(pattern.regex, collect)
} else {
const search = coerceToString(requireDataArgument(name, 0, pattern, node))
const search = coerceToString(requireDataArgument(name, 0, pattern))
if (name === "replace") value.replace(search, collect)
else value.replaceAll(search, collect)
}
@@ -91,11 +85,11 @@ export const stringGlobal = <R>(runner: Runner<R>) => {
const codeUnits = (name: string, op: (...codes: Array<number>) => string): Method => [
name,
1,
(_, args, node) =>
(_, args) =>
op(
...args.map((arg) => {
if (typeof arg !== "number") {
throw new InterpreterRuntimeError(`String.${name} expects number arguments.`, node)
throw typeError(`String.${name} expects number arguments.`)
}
return arg
}),
@@ -106,52 +100,50 @@ export const stringGlobal = <R>(runner: Runner<R>) => {
codeUnits("fromCodePoint", String.fromCodePoint),
])
const self = (thisValue: unknown, name: string, node: AstNode): string => {
const self = (thisValue: unknown, name: string): string => {
if (typeof thisValue === "string") return thisValue
if (thisValue === null || thisValue === undefined) {
throw new InterpreterRuntimeError(`String.prototype.${name} called on null or undefined.`, node)
throw typeError(`String.prototype.${name} called on null or undefined.`)
}
return coerceToString(thisValue)
}
// Coerce arguments like native JS; opaque runtime references still reject.
const str = (name: string, args: Array<unknown>, index: number, node: AstNode): string =>
coerceToString(requireDataArgument(name, index, args[index], node))
const num = (name: string, args: Array<unknown>, index: number, node: AstNode): number =>
coerceToNumber(requireDataArgument(name, index, args[index], node))
const optNum = (name: string, args: Array<unknown>, index: number, node: AstNode): number | undefined =>
args[index] === undefined ? undefined : num(name, args, index, node)
const optStr = (name: string, args: Array<unknown>, index: number, node: AstNode): string | undefined =>
args[index] === undefined ? undefined : str(name, args, index, node)
const rejectRegex = (name: string, args: Array<unknown>, node: AstNode): void => {
const str = (name: string, args: Array<unknown>, index: number): string =>
coerceToString(requireDataArgument(name, index, args[index]))
const num = (name: string, args: Array<unknown>, index: number): number =>
coerceToNumber(requireDataArgument(name, index, args[index]))
const optNum = (name: string, args: Array<unknown>, index: number): number | undefined =>
args[index] === undefined ? undefined : num(name, args, index)
const optStr = (name: string, args: Array<unknown>, index: number): string | undefined =>
args[index] === undefined ? undefined : str(name, args, index)
const rejectRegex = (name: string, args: Array<unknown>): void => {
if (args[0] instanceof ProgramRegExp) {
throw new InterpreterRuntimeError(
throw typeError(
`String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`,
node,
)
}
}
const simple = (
name: string,
length: number,
op: (value: string, args: Array<unknown>, node: AstNode) => unknown,
): Method => [name, length, (thisValue, args, node) => op(self(thisValue, name, node), args, node)]
const simple = (name: string, length: number, op: (value: string, args: Array<unknown>) => unknown): Method => [
name,
length,
(thisValue, args) => op(self(thisValue, name), args),
]
const replace = (name: "replace" | "replaceAll") =>
simple(name, 2, (value, args, node) => {
if (isSupportedCallback(args[1])) return replaceWithCallback(runner, value, name, args, node)
simple(name, 2, (value, args) => {
if (isSupportedCallback(args[1])) return replaceWithCallback(runner, value, name, args)
if (typeofValue(args[1]) === "function") {
throw new InterpreterRuntimeError(
throw typeError(
`String.${name} cannot use this callable as a replacer; wrap it in an arrow function, e.g. (match) => tools.ns.tool(match).`,
node,
)
}
if (args[0] instanceof ProgramRegExp) {
const pattern = args[0].regex
const replacement = str(name, args, 1, node)
if (name === "replaceAll") replaceAllNeedsGlobal(pattern, node)
const replacement = str(name, args, 1)
if (name === "replaceAll") replaceAllNeedsGlobal(pattern)
return name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement)
}
if (name === "replace") return value.replace(str(name, args, 0, node), str(name, args, 1, node))
return value.replaceAll(str(name, args, 0, node), str(name, args, 1, node))
if (name === "replace") return value.replace(str(name, args, 0), str(name, args, 1))
return value.replaceAll(str(name, args, 0), str(name, args, 1))
})
methods(protos, protos.String, [
@@ -165,68 +157,60 @@ export const stringGlobal = <R>(runner: Runner<R>) => {
simple("trimEnd", 0, (value) => value.trimEnd()),
simple("trimRight", 0, (value) => value.trimEnd()),
// Locale/options are deliberately unsupported; comparison uses the host default locale.
simple("localeCompare", 1, (value, args, node) => value.localeCompare(str("localeCompare", args, 0, node))),
simple("normalize", 0, (value, args, node) => {
const form = optStr("normalize", args, 0, node)
simple("localeCompare", 1, (value, args) => value.localeCompare(str("localeCompare", args, 0))),
simple("normalize", 0, (value, args) => {
const form = optStr("normalize", args, 0)
try {
return value.normalize(form)
} catch {
throw rangeError(
`String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`,
node,
)
}
}),
simple("split", 2, (value, args, node) => {
simple("split", 2, (value, args) => {
const wrap = (parts: Array<string>) => new ProgramArray(protos.Array, parts)
// Native: an undefined separator returns the whole string, not a split on "undefined",
// unless the limit truncates to zero.
const requestedLimit = optNum("split", args, 1, node)
const requestedLimit = optNum("split", args, 1)
if (args[0] === undefined) {
return wrap(requestedLimit !== undefined && requestedLimit >>> 0 === 0 ? [] : [value])
}
if (args[0] instanceof ProgramRegExp) return wrap(value.split(args[0].regex, requestedLimit))
return wrap(
value.split(str("split", args, 0, node), requestedLimit === undefined ? undefined : requestedLimit >>> 0),
)
return wrap(value.split(str("split", args, 0), requestedLimit === undefined ? undefined : requestedLimit >>> 0))
}),
simple("slice", 2, (value, args, node) =>
value.slice(optNum("slice", args, 0, node), optNum("slice", args, 1, node)),
),
simple("includes", 1, (value, args, node) => {
rejectRegex("includes", args, node)
return value.includes(str("includes", args, 0, node), optNum("includes", args, 1, node))
simple("slice", 2, (value, args) => value.slice(optNum("slice", args, 0), optNum("slice", args, 1))),
simple("includes", 1, (value, args) => {
rejectRegex("includes", args)
return value.includes(str("includes", args, 0), optNum("includes", args, 1))
}),
simple("startsWith", 1, (value, args, node) => {
rejectRegex("startsWith", args, node)
return value.startsWith(str("startsWith", args, 0, node), optNum("startsWith", args, 1, node))
simple("startsWith", 1, (value, args) => {
rejectRegex("startsWith", args)
return value.startsWith(str("startsWith", args, 0), optNum("startsWith", args, 1))
}),
simple("endsWith", 1, (value, args, node) => {
rejectRegex("endsWith", args, node)
return value.endsWith(str("endsWith", args, 0, node), optNum("endsWith", args, 1, node))
simple("endsWith", 1, (value, args) => {
rejectRegex("endsWith", args)
return value.endsWith(str("endsWith", args, 0), optNum("endsWith", args, 1))
}),
simple("indexOf", 1, (value, args, node) =>
value.indexOf(str("indexOf", args, 0, node), optNum("indexOf", args, 1, node)),
),
simple("lastIndexOf", 1, (value, args, node) =>
value.lastIndexOf(str("lastIndexOf", args, 0, node), optNum("lastIndexOf", args, 1, node)),
simple("indexOf", 1, (value, args) => value.indexOf(str("indexOf", args, 0), optNum("indexOf", args, 1))),
simple("lastIndexOf", 1, (value, args) =>
value.lastIndexOf(str("lastIndexOf", args, 0), optNum("lastIndexOf", args, 1)),
),
replace("replace"),
replace("replaceAll"),
simple("match", 1, (value, args, node) => {
const pattern = toHostRegex(args[0], "match", node)
simple("match", 1, (value, args) => {
const pattern = toHostRegex(args[0], "match")
const matched = value.match(pattern)
if (matched === null) return null
// Preserve the own `index` and `groups` properties on non-global matches.
if (pattern.global) return toProgram(protos, matched, "String.match result")
return matchToValue(protos, matched)
}),
simple("matchAll", 1, (value, args, node) => {
const pattern = toHostRegex(args[0], "matchAll", node, "g")
simple("matchAll", 1, (value, args) => {
const pattern = toHostRegex(args[0], "matchAll", "g")
if (!pattern.global) {
throw new InterpreterRuntimeError(
throw typeError(
`String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`,
node,
)
}
return new ProgramArray(
@@ -234,35 +218,27 @@ export const stringGlobal = <R>(runner: Runner<R>) => {
Array.from(value.matchAll(pattern), (match) => matchToValue(protos, match)),
)
}),
simple("search", 1, (value, args, node) => value.search(toHostRegex(args[0], "search", node))),
simple("repeat", 1, (value, args, node) => {
const count = num("repeat", args, 0, node)
simple("search", 1, (value, args) => value.search(toHostRegex(args[0], "search"))),
simple("repeat", 1, (value, args) => {
const count = num("repeat", args, 0)
if (!Number.isFinite(count) || count < 0) {
throw rangeError("String.repeat expects a finite non-negative count.", node)
throw rangeError("String.repeat expects a finite non-negative count.")
}
return value.repeat(count)
}),
simple("padStart", 1, (value, args, node) =>
value.padStart(num("padStart", args, 0, node), optStr("padStart", args, 1, node)),
),
simple("padEnd", 1, (value, args, node) =>
value.padEnd(num("padEnd", args, 0, node), optStr("padEnd", args, 1, node)),
),
simple("charAt", 1, (value, args, node) => value.charAt(optNum("charAt", args, 0, node) ?? 0)),
simple("at", 1, (value, args, node) => value.at(optNum("at", args, 0, node) ?? 0)),
simple("substring", 2, (value, args, node) =>
value.substring(optNum("substring", args, 0, node) ?? 0, optNum("substring", args, 1, node)),
),
simple("substr", 2, (value, args, node) =>
value.substr(optNum("substr", args, 0, node) ?? 0, optNum("substr", args, 1, node)),
simple("padStart", 1, (value, args) => value.padStart(num("padStart", args, 0), optStr("padStart", args, 1))),
simple("padEnd", 1, (value, args) => value.padEnd(num("padEnd", args, 0), optStr("padEnd", args, 1))),
simple("charAt", 1, (value, args) => value.charAt(optNum("charAt", args, 0) ?? 0)),
simple("at", 1, (value, args) => value.at(optNum("at", args, 0) ?? 0)),
simple("substring", 2, (value, args) =>
value.substring(optNum("substring", args, 0) ?? 0, optNum("substring", args, 1)),
),
simple("substr", 2, (value, args) => value.substr(optNum("substr", args, 0) ?? 0, optNum("substr", args, 1))),
simple("isWellFormed", 0, (value) => value.isWellFormed()),
simple("toWellFormed", 0, (value) => value.toWellFormed()),
simple("charCodeAt", 1, (value, args, node) => value.charCodeAt(optNum("charCodeAt", args, 0, node) ?? 0)),
simple("codePointAt", 1, (value, args, node) => value.codePointAt(optNum("codePointAt", args, 0, node) ?? 0)),
simple("concat", 1, (value, args, node) =>
value.concat(...args.map((_, index) => str("concat", args, index, node))),
),
simple("charCodeAt", 1, (value, args) => value.charCodeAt(optNum("charCodeAt", args, 0) ?? 0)),
simple("codePointAt", 1, (value, args) => value.codePointAt(optNum("codePointAt", args, 0) ?? 0)),
simple("concat", 1, (value, args) => value.concat(...args.map((_, index) => str("concat", args, index)))),
])
return string
}
+56 -72
View File
@@ -2,7 +2,7 @@ import { Effect } from "effect"
import { toProgram, ToolRuntimeError } from "../data.js"
import type { Prototypes } from "../interpreter/intrinsics.js"
import { constructor, fn, type Method, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { type AstNode, InterpreterRuntimeError, uriError } from "../interpreter/model.js"
import { PendingThrow, typeError, uriError } from "../interpreter/model.js"
import {
defineAccessor,
entries,
@@ -43,15 +43,12 @@ const uriFunctions: Record<UriFunction, (value: string) => string> = {
}
export const uriGlobal = <R>(runner: Runner<R>, name: UriFunction) =>
fn<R>(runner.prototypes, name, 1, (_, args, node) => {
fn<R>(runner.prototypes, name, 1, (_, args) => {
const value = uriArgument(runner.prototypes, args[0], `${name} input`)
try {
return uriFunctions[name](value)
} catch (error) {
throw uriError(
`${name} received malformed URI data: ${error instanceof Error ? error.message : String(error)}`,
node,
)
throw uriError(`${name} received malformed URI data: ${error instanceof Error ? error.message : String(error)}`)
}
})
@@ -61,32 +58,29 @@ const urlArgument = (protos: Prototypes, value: unknown, label: string): string
export const urlGlobal = <R>(runner: Runner<R>) => {
const protos = runner.prototypes
const proto = protos.URL
const construct = (args: Array<unknown>, into: ProgramObject, node: AstNode): ProgramURL => {
const construct = (args: Array<unknown>, into: ProgramObject): ProgramURL => {
if (args.length === 0) {
throw new InterpreterRuntimeError("new URL(...) requires a URL string and an optional base URL.", node)
throw typeError("new URL(...) requires a URL string and an optional base URL.")
}
const input = urlArgument(protos, args[0], "new URL input")
const base = args[1] === undefined ? undefined : urlArgument(protos, args[1], "new URL base")
try {
return new ProgramURL(into, protos.URLSearchParams, new URL(input, base))
} catch {
throw new InterpreterRuntimeError(
`new URL(...) received an invalid URL${base === undefined ? "" : " or base URL"}.`,
node,
)
throw typeError(`new URL(...) received an invalid URL${base === undefined ? "" : " or base URL"}.`)
}
}
const url = constructor<R>(protos, proto, {
name: "URL",
length: 1,
call: requiresNew("URL"),
construct: (args, newTarget, node) => Effect.sync(() => construct(args, prototypeFrom(newTarget, proto), node)),
construct: (args, newTarget) => Effect.sync(() => construct(args, prototypeFrom(newTarget, proto))),
})
const parse = (name: "canParse" | "parse"): Method => [
name,
1,
(_, args, node) => {
if (args.length === 0) throw new InterpreterRuntimeError(`URL.${name} requires a URL argument.`, node)
(_, args) => {
if (args.length === 0) throw typeError(`URL.${name} requires a URL argument.`)
const input = urlArgument(protos, args[0], `URL.${name} input`)
const base = args[1] === undefined ? undefined : urlArgument(protos, args[1], `URL.${name} base`)
try {
@@ -99,8 +93,7 @@ export const urlGlobal = <R>(runner: Runner<R>) => {
]
methods(protos, url, [parse("canParse"), parse("parse")])
const self = (thisValue: unknown, name: string, node?: AstNode) =>
receiver(ProgramURL, thisValue, `URL.prototype.${name}`, node)
const self = (thisValue: unknown, name: string) => receiver(ProgramURL, thisValue, `URL.prototype.${name}`)
for (const name of urlProperties) {
defineAccessor(
proto,
@@ -113,25 +106,25 @@ export const urlGlobal = <R>(runner: Runner<R>) => {
try {
;(target.url as unknown as Record<string, string>)[name] = uriArgument(protos, value, `URL.${name} value`)
} catch (error) {
if (error instanceof InterpreterRuntimeError || error instanceof ToolRuntimeError) throw error
throw new InterpreterRuntimeError(`URL.${name} received an invalid value.`)
if (error instanceof PendingThrow || error instanceof ToolRuntimeError) throw error
throw typeError(`URL.${name} received an invalid value.`)
}
},
)
}
defineAccessor(proto, "searchParams", (thisValue) => self(thisValue, "searchParams").searchParams)
methods(protos, proto, [
["toString", 0, (thisValue, _, node) => self(thisValue, "toString", node).url.href],
["toJSON", 0, (thisValue, _, node) => self(thisValue, "toJSON", node).url.href],
["toString", 0, (thisValue) => self(thisValue, "toString").url.href],
["toJSON", 0, (thisValue) => self(thisValue, "toJSON").url.href],
])
return url
}
const readPair = <R>(runner: Runner<R>, value: unknown, node: AstNode): Effect.Effect<Array<string>, unknown, R> =>
const readPair = <R>(runner: Runner<R>, value: unknown): Effect.Effect<Array<string>, unknown, R> =>
Effect.gen(function* () {
const cursor = yield* runner.syncIterator(value, node)
const cursor = yield* runner.syncIterator(value)
if (cursor === undefined) {
throw new InterpreterRuntimeError("new URLSearchParams(...) expects iterable [name, value] pairs.", node)
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
}
const items: Array<string> = []
while (true) {
@@ -150,7 +143,6 @@ const constructURLSearchParams = <R>(
runner: Runner<R>,
init: unknown,
proto: ProgramObject,
node: AstNode,
): Effect.Effect<ProgramURLSearchParams, unknown, R> => {
const wrap = (params: URLSearchParams) => new ProgramURLSearchParams(proto, params)
if (init === undefined) return Effect.succeed(wrap(new URLSearchParams()))
@@ -160,31 +152,27 @@ const constructURLSearchParams = <R>(
return Effect.succeed(wrap(new URLSearchParams(coerceToString(init))))
}
return Effect.gen(function* () {
const cursor = yield* runner.syncIterator(init, node)
const cursor = yield* runner.syncIterator(init)
if (cursor !== undefined) {
const pairs: Array<Array<string>> = []
while (true) {
const step = yield* cursor.next
if (step.done) {
if (pairs.some((entry) => entry.length !== 2)) {
throw new InterpreterRuntimeError("new URLSearchParams(...) expects iterable [name, value] pairs.", node)
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
}
return wrap(new URLSearchParams(pairs.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])))
}
pairs.push(yield* preserveConsumerError(cursor, readPair(runner, step.value, node)))
pairs.push(yield* preserveConsumerError(cursor, readPair(runner, step.value)))
}
}
if (isRuntimeReference(init)) {
throw new InterpreterRuntimeError(
"new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.",
node,
)
throw typeError("new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.")
}
if (isWrapper(init)) return wrap(new URLSearchParams())
if (!(init instanceof ProgramObject)) {
throw new InterpreterRuntimeError(
throw typeError(
"new URLSearchParams(...) expects a query string, data object, iterable pairs, or URLSearchParams.",
node,
)
}
return wrap(
@@ -199,20 +187,16 @@ export const urlSearchParamsGlobal = <R>(runner: Runner<R>) => {
const searchParams = constructor<R>(protos, proto, {
name: "URLSearchParams",
call: requiresNew("URLSearchParams"),
construct: (args, newTarget, node) =>
constructURLSearchParams(runner, args[0], prototypeFrom(newTarget, proto), node),
construct: (args, newTarget) => constructURLSearchParams(runner, args[0], prototypeFrom(newTarget, proto)),
})
const self = (thisValue: unknown, name: string, node?: AstNode) =>
receiver(ProgramURLSearchParams, thisValue, `URLSearchParams.prototype.${name}`, node)
const self = (thisValue: unknown, name: string) =>
receiver(ProgramURLSearchParams, thisValue, `URLSearchParams.prototype.${name}`)
const wrap = (items: Array<unknown>) => new ProgramArray(protos.Array, items)
const arg = (name: string, args: Array<unknown>, index: number): string =>
uriArgument(protos, args[index], `URLSearchParams.${name} argument ${index + 1}`)
const requireArgs = (name: string, args: Array<unknown>, count: number, node: AstNode): void => {
const requireArgs = (name: string, args: Array<unknown>, count: number): void => {
if (args.length < count) {
throw new InterpreterRuntimeError(
`URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`,
node,
)
throw typeError(`URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`)
}
}
defineAccessor(proto, "size", (thisValue) => self(thisValue, "size").params.size)
@@ -220,18 +204,18 @@ export const urlSearchParamsGlobal = <R>(runner: Runner<R>) => {
[
"append",
2,
(thisValue, args, node) => {
requireArgs("append", args, 2, node)
self(thisValue, "append", node).params.append(arg("append", args, 0), arg("append", args, 1))
(thisValue, args) => {
requireArgs("append", args, 2)
self(thisValue, "append").params.append(arg("append", args, 0), arg("append", args, 1))
return undefined
},
],
[
"delete",
1,
(thisValue, args, node) => {
requireArgs("delete", args, 1, node)
const params = self(thisValue, "delete", node).params
(thisValue, args) => {
requireArgs("delete", args, 1)
const params = self(thisValue, "delete").params
if (args[1] !== undefined) params.delete(arg("delete", args, 0), arg("delete", args, 1))
else params.delete(arg("delete", args, 0))
return undefined
@@ -240,25 +224,25 @@ export const urlSearchParamsGlobal = <R>(runner: Runner<R>) => {
[
"get",
1,
(thisValue, args, node) => {
requireArgs("get", args, 1, node)
return self(thisValue, "get", node).params.get(arg("get", args, 0))
(thisValue, args) => {
requireArgs("get", args, 1)
return self(thisValue, "get").params.get(arg("get", args, 0))
},
],
[
"getAll",
1,
(thisValue, args, node) => {
requireArgs("getAll", args, 1, node)
return wrap(self(thisValue, "getAll", node).params.getAll(arg("getAll", args, 0)))
(thisValue, args) => {
requireArgs("getAll", args, 1)
return wrap(self(thisValue, "getAll").params.getAll(arg("getAll", args, 0)))
},
],
[
"has",
1,
(thisValue, args, node) => {
requireArgs("has", args, 1, node)
const params = self(thisValue, "has", node).params
(thisValue, args) => {
requireArgs("has", args, 1)
const params = self(thisValue, "has").params
return args[1] !== undefined
? params.has(arg("has", args, 0), arg("has", args, 1))
: params.has(arg("has", args, 0))
@@ -267,36 +251,36 @@ export const urlSearchParamsGlobal = <R>(runner: Runner<R>) => {
[
"set",
2,
(thisValue, args, node) => {
requireArgs("set", args, 2, node)
self(thisValue, "set", node).params.set(arg("set", args, 0), arg("set", args, 1))
(thisValue, args) => {
requireArgs("set", args, 2)
self(thisValue, "set").params.set(arg("set", args, 0), arg("set", args, 1))
return undefined
},
],
[
"sort",
0,
(thisValue, _, node) => {
self(thisValue, "sort", node).params.sort()
(thisValue) => {
self(thisValue, "sort").params.sort()
return undefined
},
],
["keys", 0, (thisValue, _, node) => wrap(Array.from(self(thisValue, "keys", node).params.keys()))],
["values", 0, (thisValue, _, node) => wrap(Array.from(self(thisValue, "values", node).params.values()))],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").params.keys()))],
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").params.values()))],
[
"entries",
0,
(thisValue, _, node) =>
wrap(Array.from(self(thisValue, "entries", node).params.entries(), ([key, value]) => wrap([key, value]))),
(thisValue) =>
wrap(Array.from(self(thisValue, "entries").params.entries(), ([key, value]) => wrap([key, value]))),
],
["toString", 0, (thisValue, _, node) => self(thisValue, "toString", node).params.toString()],
["toString", 0, (thisValue) => self(thisValue, "toString").params.toString()],
[
"forEach",
1,
(thisValue, args, node) => {
requireArgs("forEach", args, 1, node)
const target = self(thisValue, "forEach", node)
const apply = applyCollectionCallback(runner, args[0], "URLSearchParams.forEach", node)
(thisValue, args) => {
requireArgs("forEach", args, 1)
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(runner, args[0], "URLSearchParams.forEach")
return Effect.gen(function* () {
for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target])
return undefined
+5 -5
View File
@@ -1,6 +1,6 @@
import { toProgram } from "../data.js"
import { fn } from "../interpreter/native.js"
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { typeError } from "../interpreter/model.js"
import {
get,
isWrapper,
@@ -55,7 +55,7 @@ export const coerceToNumber = (value: unknown): number => {
export type Coercion = "Number" | "String" | "Boolean" | "parseInt" | "parseFloat" | "isFinite" | "isNaN"
const coerce = <R>(runner: Runner<R>, name: Coercion, args: Array<unknown>, node: AstNode): unknown => {
const coerce = <R>(runner: Runner<R>, name: Coercion, args: Array<unknown>): unknown => {
// Native: Number() is 0 and String() is "", unlike their undefined-argument forms; the
// other coercers match native through the undefined-argument path below.
if (args.length === 0) {
@@ -80,7 +80,7 @@ const coerce = <R>(runner: Runner<R>, name: Coercion, args: Array<unknown>, node
if (name === "parseInt") {
const radix = args[1]
if (radix !== undefined && typeof radix !== "number") {
throw new InterpreterRuntimeError("parseInt expects a numeric radix.", node)
throw typeError("parseInt expects a numeric radix.")
}
return parseInt(coerceToString(value), radix)
}
@@ -90,6 +90,6 @@ const coerce = <R>(runner: Runner<R>, name: Coercion, args: Array<unknown>, node
/** A global coercion function such as `Number` or `parseInt`. */
export const coercion = <R>(runner: Runner<R>, name: Coercion, length = 1): NativeFunction<R> =>
fn(runner.prototypes, name, length, (_, args, node) =>
toProgram(runner.prototypes, coerce(runner, name, args, node), `${name} result`),
fn(runner.prototypes, name, length, (_, args) =>
toProgram(runner.prototypes, coerce(runner, name, args), `${name} result`),
)
+4 -4
View File
@@ -1,5 +1,5 @@
import { fn, methods } from "../interpreter/native.js"
import { InterpreterRuntimeError } from "../interpreter/model.js"
import { typeError } from "../interpreter/model.js"
import { ProgramObject } from "../interpreter/objects.js"
import type { Runner } from "../interpreter/runner.js"
import { coerceToString } from "./value.js"
@@ -7,13 +7,13 @@ import { coerceToString } from "./value.js"
// WebIDL DOMString conversion: a missing argument is a TypeError, anything else stringifies. Invalid input is a
// TypeError as well; browsers throw a DOMException named InvalidCharacterError, which CodeMode does not have.
export const base64Global = <R>(runner: Runner<R>, name: "atob" | "btoa") =>
fn<R>(runner.prototypes, name, 1, (_, args, node) => {
if (args.length === 0) throw new InterpreterRuntimeError(`${name} requires 1 argument (a string)`, node)
fn<R>(runner.prototypes, name, 1, (_, args) => {
if (args.length === 0) throw typeError(`${name} requires 1 argument (a string)`)
const input = coerceToString(args[0])
try {
return name === "atob" ? atob(input) : btoa(input)
} catch {
throw new InterpreterRuntimeError("The string contains invalid characters.", node)
throw typeError("The string contains invalid characters.")
}
})
+1 -1
View File
@@ -347,7 +347,7 @@ describe("CodeMode console capture", () => {
)
expect(result.ok ? undefined : result.logs).toStrictEqual(["before failure"])
expect(result.ok ? undefined : result.error.message).toBe("Uncaught: boom")
expect(result.ok ? undefined : result.error.message).toBe("Error: boom")
})
test("prints NaN and Infinity literally instead of the JSON null", async () => {
+178
View File
@@ -0,0 +1,178 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
// One failure is one program error object, and rethrowing it keeps the diagnostic it started with.
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const value = async (code: string) => {
const result = await run(code)
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
const error = async (code: string) => {
const result = await run(code)
if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
return result.error
}
describe("error identity", () => {
test("awaiting the same rejected promise twice yields the same error object", async () => {
expect(
await value(`
const fail = async () => { null.foo }
const p = fail()
const a = await p.catch((e) => e)
try { await p } catch (b) { return a === b }
`),
).toBe(true)
})
test("allSettled reasons for the same failure are identical", async () => {
expect(
await value(`
const fail = async () => { null.foo }
const p = fail()
const [a, b] = await Promise.allSettled([p, p])
return [a.reason === b.reason, a.reason.name, a.reason.message]
`),
).toEqual([true, "TypeError", "Cannot read properties of null (reading 'foo')."])
})
test("Promise.any collects the same object a direct catch would", async () => {
expect(
await value(`
const fail = async () => { null.foo }
const p = fail()
const direct = await p.catch((e) => e)
try { await Promise.any([p]) } catch (aggregate) { return aggregate.errors[0] === direct }
`),
).toBe(true)
})
})
describe("rethrown interpreter failures", () => {
test("keep their diagnostic kind and source location", async () => {
const failure = await error(`try { switch (Symbol) {} } catch (e) { throw e }`)
expect(failure.kind).toBe("InvalidDataValue")
expect(failure.message).toStartWith("TypeError: Switch discriminants must be data values. (line ")
expect(failure.location).toBeDefined()
})
test("keep their location through a rejection handler", async () => {
const direct = await error(`
const fail = async () => { null.foo }
await fail()
`)
const rethrown = await error(`
const fail = async () => { null.foo }
await fail().catch((e) => { throw e })
`)
expect(direct.location).toBeDefined()
expect(rethrown).toEqual(direct)
})
})
describe("uncaught program throws", () => {
test("an Error reports as name: message", async () => {
const failure = await error(`throw new TypeError("bad input")`)
expect(failure).toEqual({ kind: "ExecutionFailure", message: "TypeError: bad input" })
})
test("a custom name is honored", async () => {
const failure = await error(`const e = new Error("x"); e.name = "ValidationError"; throw e`)
expect(failure.message).toBe("ValidationError: x")
})
test("non-Error values keep the Uncaught prefix", async () => {
expect((await error(`throw "boom"`)).message).toBe("Uncaught: boom")
expect((await error(`throw { code: 7 }`)).message).toBe('Uncaught: {"code":7}')
})
})
describe("host errors escaping built-ins", () => {
test("become the same-named program error", async () => {
expect(
await value(`
try { (1).toFixed(200) } catch (e) { return [e.name, e instanceof RangeError, e.message] }
`),
).toEqual(["RangeError", true, "toFixed() argument must be between 0 and 100"])
})
test("report the location of the call that raised them", async () => {
const failure = await error(`return [1].map((n) => n.toFixed(200))`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 23)")
})
test("a built-in that rejects its arguments before doing any work is located at the call", async () => {
expect((await error(`new Promise(Symbol)`)).message).toEndWith("(line 1, col 1)")
})
test("a rejection born inside a promise the built-in created is located at the creating call", async () => {
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 14)")
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 14)")
expect((await error(`return await Promise.all({ [Symbol.iterator]: () => ({ next: 1 }) })`)).message).toEndWith(
"(line 1, col 14)",
)
expect((await error(`let p; p = Promise.resolve().then(() => p); return await p`)).message).toEndWith(
"(line 2, col 5)",
)
})
test("an un-awaited rejection born inside promise machinery keeps its location in the warning", async () => {
const result = await run(`Promise.all(1); return 1`)
expect(result.ok && result.warnings?.[0]?.message).toEndWith(
"TypeError: Promise.all expects an array or other synchronous iterable. (line 1, col 1)",
)
})
test("a failure inside a built-in called by another built-in is located at the outer call", async () => {
const failure = await error(`return Array.from({ [Symbol.iterator]: () => ({ next: 1 }) })`)
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 8)")
})
})
describe("call depth", () => {
test("runaway recursion fails fast with a catchable RangeError", async () => {
const started = Date.now()
expect(
await value(`
const f = (n) => f(n + 1)
try { f(0) } catch (e) { return [e.name, e instanceof RangeError, e.message] }
`),
).toEqual(["RangeError", true, "Maximum call stack size exceeded"])
expect(Date.now() - started).toBeLessThan(2000)
})
test("uncaught overflow reports the call that overflowed", async () => {
const failure = await error(`const f = (n) => f(n + 1); return f(0)`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 18)")
})
test("the limit is 10000 nested calls", async () => {
expect(await value(`let depth = 0; const f = () => { depth++; f() }; try { f() } catch { return depth }`)).toBe(
10000,
)
expect(await value(`const f = (n) => (n === 0 ? 0 : 1 + f(n - 1)); return f(9000)`)).toBe(9000)
})
test("recursion through a built-in callback counts", async () => {
const failure = await error(`const f = (n) => [n].map((x) => f(x + 1)); return f(0)`)
expect(failure.message).toStartWith("RangeError: Maximum call stack size exceeded")
})
test("an await resets the depth, so long async chains are fine", async () => {
expect(
await value(`
const page = async (n) => { await null; return n === 0 ? "done" : page(n - 1) }
return await page(3000)
`),
).toBe("done")
})
test("an async function that recurses before its first await overflows like JS", async () => {
const failure = await error(`const f = async (n) => f(n + 1); return await f(0)`)
expect(failure.message).toStartWith("RangeError: Maximum call stack size exceeded")
})
})
+285
View File
@@ -0,0 +1,285 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Extension, Tool } from "../src/index.js"
class Bag {
static made = 0
static of(...items: Array<string>) {
return new this(items)
}
constructor(readonly items: Array<string> = []) {
Bag.made++
}
get size() {
return this.items.length
}
set size(length: number) {
this.items.length = length
}
add(item: string) {
this.items.push(item)
return this
}
toArray() {
return [...this.items]
}
pair() {
return { self: this, list: [this, new Bag()] }
}
async later<T>(value: T) {
return value
}
async reject(reason: unknown) {
throw reason
}
fail() {
throw new RangeError("boom")
}
get lazy() {
return Promise.resolve(1)
}
detached() {
return new Other()
}
}
class Other {}
class Big extends Bag {
double() {
return this.items.length * 2
}
}
class Vault {
secrets = new Map<string, string>()
set(key: string, value: string) {
this.secrets.set(key, value)
}
}
const held: Array<unknown> = []
const config = { retries: 3, nested: { deep: true } }
const extension = Extension.make({
name: "bag",
globals: {
Bag,
Big,
Vault,
keep: (value: unknown) => {
held.push(value)
return value
},
settings: () => config,
later: async (value: number) => value + 1,
first: (map: Map<unknown, unknown>) => map.get("k"),
},
})
const runtime = CodeMode.make({ tools: {}, extensions: [extension] })
const value = async (code: string, target = runtime) => {
const result = await Effect.runPromise(target.execute(code))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
const failure = async (code: string, target = runtime) => {
const result = await Effect.runPromise(target.execute(code))
if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
return result.error
}
describe("extension classes behave like JS", () => {
test("construct, call methods, read and write accessors", async () => {
expect(await value(`const b = new Bag(["a"]); b.add("b"); return [b.size, b.toArray()]`)).toEqual([2, ["a", "b"]])
expect(await value(`const b = new Bag(["a", "b"]); b.size = 1; return b.toArray()`)).toEqual(["a"])
})
test("instanceof, constructor, typeof, and prototype identity", async () => {
expect(
await value(
`const b = new Bag(); return [b instanceof Bag, b.constructor === Bag, typeof Bag, Bag.prototype.constructor === Bag]`,
),
).toEqual([true, true, "function", true])
})
test("statics, including `new this()` through an exposed subclass", async () => {
expect(await value(`return [Bag.of("x", "y").toArray(), Big.of("q") instanceof Big, Big.of === Bag.of]`)).toEqual([
["x", "y"],
true,
true,
])
expect(await value(`Bag.made = 0; new Bag(); new Bag(); return Bag.made`)).toBe(2)
})
test("inheritance chains to the exposed ancestor", async () => {
expect(
await value(`const b = new Big(["a"]); return [b.double(), b.add("b").size, b instanceof Bag, b instanceof Big]`),
).toEqual([2, 2, true, true])
})
test("calling a class without new throws the host TypeError", async () => {
const error = await failure(`Bag()`)
expect(error.message).toStartWith("TypeError: ")
expect(error.message).toContain("new")
})
test("a function global is callable, awaitable, and not constructible", async () => {
expect(await value(`return await later(1)`)).toBe(2)
expect((await failure(`new later()`)).message).toContain("new later(...) is not supported")
})
test("a program can patch a prototype for its own run only", async () => {
expect(await value(`Bag.prototype.add = () => "patched"; return new Bag().add("x")`)).toBe("patched")
expect(await value(`return new Bag().add("x").toArray()`)).toEqual(["x"])
})
})
describe("values are converted at the boundary, never shared", () => {
test("the same host instance is the same handle", async () => {
expect(
await value(`const b = new Bag(); const p = b.pair(); return [p.self === b, p.list[0] === b, keep(b) === b]`),
).toEqual([true, true, true])
expect(await value(`const p = new Bag().pair(); return p.list[1] instanceof Bag`)).toBe(true)
})
test("plain data passed in is a copy the program cannot change afterwards", async () => {
held.length = 0
await value(
`const o = { n: 1, list: [1], d: new Date(0), u: new URL("https://a.test/") }; keep(o); o.n = 2; o.list.push(2)`,
)
expect(held[0]).toEqual({ n: 1, list: [1], d: new Date(0), u: new URL("https://a.test/") })
})
test("plain data returned is a copy; program writes never reach the host", async () => {
expect(await value(`const s = settings(); s.retries = 0; s.nested.deep = false; return s`)).toEqual({
retries: 0,
nested: { deep: false },
})
expect(config).toEqual({ retries: 3, nested: { deep: true } })
})
test("Map and Set contents are converted element-wise, so handles unwrap inside them", async () => {
expect(await value(`const b = new Bag(); return first(new Map([["k", b]])) === b`)).toBe(true)
expect(await value(`return first(new Map([["k", { z: 1 }]]))`)).toEqual({ z: 1 })
held.length = 0
await value(`const inner = { z: 1 }; keep(new Set([inner])); inner.z = 2`)
expect([...(held[0] as Set<{ z: number }>)][0]).toEqual({ z: 1 })
})
test("a __proto__ key never reaches the host object", async () => {
held.length = 0
await value(`keep({ __proto__: { polluted: true }, a: 1 })`)
expect(Object.assign({}, held[0] as object)).not.toHaveProperty("polluted")
expect(held[0]).toEqual({ a: 1 })
})
test("a program Error crosses as a host Error with its name and message", async () => {
held.length = 0
await value(`keep(new TypeError("bad"))`)
expect(held[0]).toBeInstanceOf(Error)
expect((held[0] as Error).name).toBe("TypeError")
expect((held[0] as Error).message).toBe("bad")
})
test("functions and promises cannot be passed in", async () => {
expect((await failure(`keep(() => 1)`)).message).toContain("Argument 1 to keep contains a function")
expect((await failure(`keep(later(1))`)).message).toContain("un-awaited Promise")
})
test("an instance of an unexposed class cannot come out", async () => {
expect((await failure(`new Bag().detached()`)).message).toContain("returned a Other, which the program cannot hold")
})
test("a getter must be synchronous", async () => {
expect((await failure(`new Bag().lazy`)).message).toContain("Bag.prototype.lazy returned a Promise")
})
})
describe("the host object behind a handle is unreachable", () => {
test("enumeration, spread, and JSON see no own properties", async () => {
expect(
await value(`const b = new Bag(["a"]); return [Object.keys(b), Object.entries({ ...b }), String(b)]`),
).toEqual([[], [], "[object Object]"])
})
test("a handle cannot be returned, stringified, or handed to a tool", async () => {
expect(await failure(`return new Bag()`)).toMatchObject({ kind: "InvalidDataValue" })
expect((await failure(`return JSON.stringify(new Bag())`)).message).toContain("contains a Bag")
const tools = CodeMode.make({
extensions: [extension],
tools: {
echo: Tool.make({
description: "Echo",
input: Schema.Struct({ v: Schema.Unknown }),
output: Schema.Unknown,
execute: (input) => Effect.succeed(input.v),
}),
},
})
expect((await failure(`return await tools.echo({ v: new Bag() })`, tools)).message).toContain("contains a Bag")
})
test("a method only runs on a handle of its own class", async () => {
expect((await failure(`const add = new Bag().add; add("x")`)).message).toContain(
"Illegal invocation: Bag.prototype.add called on undefined",
)
expect((await failure(`const o = { add: Bag.prototype.add }; o.add("x")`)).message).toContain(
"called on a data object",
)
const vault = new Vault()
const target = CodeMode.make({
extensions: [Extension.make({ name: "vault", globals: { Bag, Vault, vault: () => vault } })],
})
expect((await failure(`const v = vault(); v.add = Bag.prototype.add; v.add("x")`, target)).message).toContain(
"Illegal invocation: Bag.prototype.add called on a Vault",
)
expect(vault.secrets.size).toBe(0)
})
test("reading an accessor off the prototype itself is an illegal invocation", async () => {
expect((await failure(`Bag.prototype.size`)).message).toContain("Illegal invocation")
})
})
describe("host errors", () => {
test("a synchronous throw becomes the matching program error", async () => {
expect(await value(`try { new Bag().fail() } catch (e) { return [e instanceof RangeError, e.message] }`)).toEqual([
true,
"boom",
])
})
test("a rejection becomes a program rejection without exposing the reason object", async () => {
expect(
await value(
`try { await new Bag().reject(new TypeError("bad")) } catch (e) { return [e instanceof TypeError, e.message] }`,
),
).toEqual([true, "bad"])
expect(
await value(`try { await new Bag().reject("plain") } catch (e) { return [e instanceof Error, e.message] }`),
).toEqual([true, "plain"])
})
})
describe("configuration", () => {
test("extension calls are not tool calls", async () => {
const limited = CodeMode.make({ extensions: [extension], limits: { maxToolCalls: 0 } })
const result = await Effect.runPromise(limited.execute(`new Bag().add("x"); return await later(1)`))
expect(result.ok).toBe(true)
expect(result.toolCalls).toEqual([])
})
test("a global must be a class or a function", () => {
expect(() => Extension.make({ name: "bad", globals: { n: 1 as never } })).toThrow(
'Extension "bad" global "n" must be a class or a function.',
)
})
test("a global may not shadow a built-in or another extension", () => {
expect(() => CodeMode.make({ extensions: [Extension.make({ name: "web", globals: { URL: class {} } })] })).toThrow(
'Extension "web" global "URL" is already defined.',
)
expect(() =>
CodeMode.make({ extensions: [extension, Extension.make({ name: "again", globals: { Bag: class {} } })] }),
).toThrow('Extension "again" global "Bag" is already defined.')
})
})
+13 -9
View File
@@ -30,27 +30,31 @@ describe("new on a non-constructible callee", () => {
// Number is a real constructor in JS, so the message must not claim otherwise.
const failure = await error(`return new Number(42)`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toStartWith("new Number(...) is not supported; call Number(...) without new instead.")
expect(failure.message).toStartWith(
"TypeError: new Number(...) is not supported; call Number(...) without new instead.",
)
expect(failure.suggestions).toBeUndefined()
expect((await error(`return new String("a")`)).message).toStartWith("new String(...) is not supported")
expect((await error(`return new String("a")`)).message).toStartWith("TypeError: new String(...) is not supported")
expect((await error(`return new Math.abs(1)`)).message).toStartWith(
"new Math.abs(...) is not supported; call Math.abs(...) without new instead.",
"TypeError: new Math.abs(...) is not supported; call Math.abs(...) without new instead.",
)
})
test("non-callable values are not constructors", async () => {
expect((await error(`return new tools.echo()`)).message).toStartWith("tools.echo is not a constructor.")
expect((await error(`return new (1)()`)).message).toStartWith("The called value is not a constructor.")
expect((await error(`const Date = 5; return new Date()`)).message).toStartWith("Date is not a constructor.")
expect((await error(`return new tools.echo()`)).message).toStartWith("TypeError: tools.echo is not a constructor.")
expect((await error(`return new (1)()`)).message).toStartWith("TypeError: The called value is not a constructor.")
expect((await error(`const Date = 5; return new Date()`)).message).toStartWith(
"TypeError: Date is not a constructor.",
)
})
test("user-defined functions explain the documented gap", async () => {
const failure = await error(`function Point(x) { return { x } }; return new Point(1)`)
expect(failure.message).toStartWith(
"Point cannot be constructed: user-defined constructors and classes are not supported. Call it as a function that returns a plain object instead.",
"TypeError: Point cannot be constructed: user-defined constructors and classes are not supported. Call it as a function that returns a plain object instead.",
)
expect((await error(`const make = () => ({}); return new make()`)).message).toStartWith(
"make cannot be constructed",
"TypeError: make cannot be constructed",
)
})
@@ -71,7 +75,7 @@ describe("new on a non-constructible callee", () => {
const failure = await error(`class A {}; return new A()`)
expect(failure.kind).toBe("UnsupportedSyntax")
expect(failure.message).toStartWith(
"Syntax 'ClassDeclaration' is not supported. This is a restricted JavaScript-like language. Supported: ",
"SyntaxError: Syntax 'ClassDeclaration' is not supported. This is a restricted JavaScript-like language. Supported: ",
)
expect(failure.message).toContain(
"Unsupported: classes, this, getters/setters, tagged templates, BigInt, and custom Symbols.",
+9 -9
View File
@@ -330,7 +330,7 @@ describe("first-class promise values", () => {
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: boom" },
])
})
@@ -363,7 +363,7 @@ describe("first-class promise values", () => {
expect(result.truncated).toBe(true)
expect(typeof result.value).toBe("string")
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: boom" },
])
})
@@ -399,9 +399,9 @@ describe("first-class promise values", () => {
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: first" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: first" },
{ kind: "ToolFailure", message: "Unhandled rejection from an un-awaited promise: Lookup refused" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: third" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: third" },
])
})
@@ -417,8 +417,8 @@ describe("first-class promise values", () => {
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: outer" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: inner" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: outer" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: inner" },
])
})
@@ -445,7 +445,7 @@ describe("first-class promise values", () => {
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.message).toBe("Uncaught: boom")
expect(result.error.message).toBe("Error: boom")
expect("warnings" in result).toBe(false)
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(1)
@@ -911,7 +911,7 @@ describe("Promise.resolve / Promise.reject", () => {
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: abandoned" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: abandoned" },
])
})
})
@@ -1068,7 +1068,7 @@ describe("promise chaining", () => {
expect(result.value).toBe("done")
// The source rejection belongs to the chain (no warning); only the derived tail warns.
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: boom" },
])
})
+2 -2
View File
@@ -737,9 +737,9 @@ describe("stdlib integration", () => {
expect(await value(`const make = (C) => new C([["a", 1]]); return make(Map).get("a")`)).toBe(1)
expect(await value(`const t = { M: Map }; return new t.M() instanceof Map`)).toBe(true)
const shadowed = await error(`const Date = 5; return new Date()`)
expect(shadowed.message).toStartWith("Date is not a constructor.")
expect(shadowed.message).toStartWith("TypeError: Date is not a constructor.")
const fn = await error(`const f = () => 1; return new f()`)
expect(fn.message).toStartWith("f cannot be constructed")
expect(fn.message).toStartWith("TypeError: f cannot be constructed")
})
test("Object.is uses SameValue semantics", async () => {
+4 -4
View File
@@ -3,7 +3,7 @@
// test262's own assert.js relies on.
import path from "node:path"
import { Cause, Effect } from "effect"
import { caughtErrorValue } from "../../src/interpreter/errors.js"
import { materialize } from "../../src/interpreter/errors.js"
import { executeProgram } from "../../src/interpreter/execute.js"
import type { Host } from "../../src/interpreter/globals.js"
import { ProgramThrow } from "../../src/interpreter/model.js"
@@ -128,13 +128,13 @@ const harness = <R>(host: Host<R>, onDone: (error: unknown) => void): ReadonlyAr
[
"throws",
3,
(_, args, node) => {
(_, args) => {
const expected = args[0] instanceof Callable ? String(get(args[0], "name")) : show(args[0])
return host.runner.invokeCallable(args[1], undefined, [], node).pipe(
return host.runner.invokeCallable(args[1], undefined, []).pipe(
Effect.matchCauseEffect({
onFailure: (cause) => {
if (cause.reasons.some(Cause.isInterruptReason)) return Effect.failCause(cause)
const thrown = caughtErrorValue(host.runner, Cause.squash(cause))
const thrown = materialize(host.runner, Cause.squash(cause))
if (!(thrown instanceof ProgramObject)) return fail(`${prefix(args[2])}Thrown value was not an object!`)
const actual = get(thrown, "constructor")
if (actual === args[0]) return Effect.void
+34 -1
View File
@@ -198,7 +198,9 @@ describe("blocked member names on tool paths", () => {
const poisoned = await failure(runtime, `const o = {}; o.__proto__.constructor("return 1")`)
expect(poisoned.message).toContain("Cannot read properties of undefined")
// Prototype mutation is confined to one run: the next program starts from fresh intrinsics.
expect(await value(runtime, `Object.prototype.polluted = 1; Array.prototype.push = 2; return ({}).polluted`)).toBe(1)
expect(await value(runtime, `Object.prototype.polluted = 1; Array.prototype.push = 2; return ({}).polluted`)).toBe(
1,
)
expect(await value(runtime, `return [({}).polluted, typeof [].push]`)).toEqual([null, "function"])
expect(Object.keys(Object.prototype)).toEqual([])
expect(Object.keys(Array.prototype)).toEqual([])
@@ -279,3 +281,34 @@ describe("canonical path collisions", () => {
expect(await value(runtime, `return await tools.issues.close({})`)).toBe("closed")
})
})
describe("tool argument prototype safety", () => {
test("a __proto__ key never reaches tool code", async () => {
let seen: unknown
const runtime = CodeMode.make({
tools: {
inspect: Tool.make({
description: "Inspect",
input: Schema.Struct({ v: Schema.Unknown }),
output: Schema.Unknown,
execute: (input) =>
Effect.sync(() => {
seen = (input as { v: unknown }).v
return null
}),
}),
},
})
await value(runtime, `return await tools.inspect({ v: { __proto__: { polluted: true }, a: 1 } })`)
expect(seen).toEqual({ a: 1 })
const merged = Object.assign({}, seen as Record<string, unknown>)
expect(merged.polluted).toBeUndefined()
expect(Object.getPrototypeOf(merged)).toBe(Object.prototype)
})
test("program results drop __proto__ keys", async () => {
const runtime = CodeMode.make({ tools: {} })
expect(await value(runtime, `return { __proto__: { polluted: true }, a: 2 }`)).toEqual({ a: 2 })
expect(await value(runtime, `return [{ __proto__: 1 }]`)).toEqual([{}])
})
})
+13 -12
View File
@@ -83,10 +83,8 @@
"devDependencies": {
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/which": "3.0.4",
"@modelcontextprotocol/server": "2.0.0",
"@opencode/http-recorder": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
@@ -95,7 +93,10 @@
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1",
"@opencode/http-recorder": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/which": "3.0.4",
"drizzle-kit": "catalog:"
},
"dependencies": {
@@ -111,20 +112,20 @@
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/vercel": "2.0.39",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
"@ff-labs/fff-bun": "0.10.5",
"@ff-labs/fff-node": "0.10.5",
"@opencode/codemode": "workspace:*",
"@opencode/ai": "workspace:*",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/client": "2.0.0",
"@opencode-ai/pty": "0.1.13",
"@opencode/schema": "workspace:*",
"@opencode/ai": "workspace:*",
"@opencode/codemode": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/plugin-browser": "workspace:*",
"@opencode/schema": "workspace:*",
"@opencode/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"@standard-schema/spec": "catalog:",
"ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8",
"diff": "catalog:",
@@ -137,8 +138,8 @@
"htmlparser2": "8.0.2",
"http-proxy-agent": "7.0.2",
"https-proxy-agent": "7.0.6",
"immer": "11.1.4",
"ignore": "7.0.5",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"tree-sitter-bash": "0.25.0",
+12 -7
View File
@@ -10,6 +10,7 @@ import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { File } from "./file.js"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { VcsPatch } from "./vcs/patch.js"
import { gitExecutable } from "./util/git-executable.js"
export class Repository extends Schema.Class<Repository>("Git.Repository")({
worktree: AbsolutePath,
@@ -313,7 +314,7 @@ const layer = Layer.effect(
) {
const result = yield* proc
.run(
ChildProcess.make("git", repositoryArgs(repository, args), {
ChildProcess.make(gitExecutable, repositoryArgs(repository, args), {
cwd: repository.worktree,
env: options?.env,
extendEnv: true,
@@ -441,10 +442,14 @@ const layer = Layer.effect(
if (!input.paths.length) return new Set<RelativePath>()
const result = yield* proc
.run(
ChildProcess.make("git", repositoryArgs(input.repository, ["check-ignore", "--no-index", "--stdin", "-z"]), {
cwd: input.repository.worktree,
extendEnv: true,
}),
ChildProcess.make(
gitExecutable,
repositoryArgs(input.repository, ["check-ignore", "--no-index", "--stdin", "-z"]),
{
cwd: input.repository.worktree,
extendEnv: true,
},
),
{ stdin: input.paths.join("\0") + "\0" },
)
.pipe(
@@ -625,7 +630,7 @@ const layer = Layer.effect(
cwd = repository.worktree,
) {
const result = yield* proc
.run(ChildProcess.make("git", args, { cwd, extendEnv: true, stdin: "ignore" }))
.run(ChildProcess.make(gitExecutable, args, { cwd, extendEnv: true, stdin: "ignore" }))
.pipe(
Effect.mapError(
(cause) => new WorktreeError({ operation, directory: worktreeDirectory, message: cause.message, cause }),
@@ -721,7 +726,7 @@ function run(cwd: string, proc: AppProcess.Interface, args: string[]) {
function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
return proc
.run(
ChildProcess.make("git", args, {
ChildProcess.make(gitExecutable, args, {
cwd,
extendEnv: true,
stdin: "ignore",
+12 -7
View File
@@ -113,7 +113,12 @@ export class AuthorizationError extends Schema.TaggedError<AuthorizationError>()
cause: Schema.Defect(),
}) {}
export type Error = CodeRequiredError | AuthorizationError
export class AttemptNotFoundError extends Schema.TaggedError<AttemptNotFoundError>()("Integration.AttemptNotFound", {
integrationID: ID,
attemptID: AttemptID,
}) {}
export type Error = CodeRequiredError | AuthorizationError | AttemptNotFoundError
export { Event } from "@opencode/schema/integration"
@@ -188,13 +193,13 @@ export interface Interface extends State.Transformable<Editor> {
readonly status: (input: {
readonly integrationID: ID
readonly attemptID: AttemptID
}) => Effect.Effect<AttemptStatus>
}) => Effect.Effect<AttemptStatus, AttemptNotFoundError>
/** Completes the attempt and stores its credential. */
readonly complete: (input: {
readonly integrationID: ID
readonly attemptID: AttemptID
readonly code?: string
}) => Effect.Effect<void, CodeRequiredError | AuthorizationError>
}) => Effect.Effect<void, CodeRequiredError | AuthorizationError | AttemptNotFoundError>
/** Cancels an attempt and releases its resources. */
readonly cancel: (input: { readonly integrationID: ID; readonly attemptID: AttemptID }) => Effect.Effect<void>
}
@@ -207,7 +212,7 @@ export interface Interface extends State.Transformable<Editor> {
readonly status: (input: {
readonly integrationID: ID
readonly attemptID: AttemptID
}) => Effect.Effect<CommandAttemptStatus>
}) => Effect.Effect<CommandAttemptStatus, AttemptNotFoundError>
readonly cancel: (input: { readonly integrationID: ID; readonly attemptID: AttemptID }) => Effect.Effect<void>
}
}
@@ -718,7 +723,7 @@ const layer = Layer.effect(
status: Effect.fn("Integration.oauth.status")(function* (input) {
const attempt = (yield* SynchronizedRef.get(attempts)).get(input.attemptID)
if (!attempt || attempt.integrationID !== input.integrationID)
return yield* Effect.die(new Error(`OAuth attempt not found: ${input.attemptID}`))
return yield* new AttemptNotFoundError(input)
if (attempt.status === "failed") {
return { status: attempt.status, message: attempt.message ?? "Authorization failed", time: attempt.time }
}
@@ -732,7 +737,7 @@ const layer = Layer.effect(
if (match.authorization.mode === "code" && input.code === undefined) return [match, current]
return [match, new Map(current).set(input.attemptID, { ...match, completing: true })]
})
if (!attempt) return yield* Effect.die(new Error(`OAuth attempt not found: ${input.attemptID}`))
if (!attempt) return yield* new AttemptNotFoundError(input)
if (attempt.status !== "pending") return
if (attempt.authorization.mode === "code" && input.code === undefined) {
return yield* new CodeRequiredError({ attemptID: input.attemptID })
@@ -764,7 +769,7 @@ const layer = Layer.effect(
status: Effect.fn("Integration.command.status")(function* (input) {
const attempt = (yield* SynchronizedRef.get(commandAttempts)).get(input.attemptID)
if (!attempt || attempt.integrationID !== input.integrationID)
return yield* Effect.die(new Error(`Command attempt not found: ${input.attemptID}`))
return yield* new AttemptNotFoundError(input)
if (attempt.status === "pending") {
return {
status: attempt.status,
+170 -312
View File
@@ -2,28 +2,28 @@ export * as McpClient from "./client.js"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import {
CallToolResultSchema,
ElicitationCompleteNotificationSchema,
ElicitRequestSchema,
type Implementation,
Client,
SdkHttpError,
StreamableHTTPClientTransport,
UnauthorizedError,
UnsupportedProtocolVersionError,
type CallToolResult as SdkCallToolResult,
type ElicitRequestFormParams,
type ElicitRequestParams,
type ElicitRequestURLParams,
type ElicitResult,
ListRootsRequestSchema,
ListToolsResultSchema,
PromptListChangedNotificationSchema,
ResourceListChangedNotificationSchema,
type LoggingMessageNotification,
LoggingMessageNotificationSchema,
ToolListChangedNotificationSchema,
ToolSchema,
} from "@modelcontextprotocol/sdk/types.js"
type GetPromptResult,
type Implementation,
type OAuthClientProvider,
type Prompt,
type ReadResourceResult,
type Resource,
type ResourceTemplateType,
type Tool,
type Transport,
type VersionNegotiationOptions,
} from "@modelcontextprotocol/client"
import { Cause, Effect, Exit, Schema } from "effect"
import { ConfigMCP } from "@opencode/schema/config/mcp"
import type { Session } from "@opencode/schema/session"
@@ -34,11 +34,9 @@ const DEFAULT_CATALOG_TIMEOUT = 30_000
const DEFAULT_EXECUTION_TIMEOUT = 12 * 60 * 60 * 1_000 // 12 hours
const toError = (error: unknown) => (error instanceof Error ? error : new Error(String(error)))
// Some servers advertise tool outputSchemas the SDK's strict validator can't resolve; this drops
// only that field so a single bad schema doesn't blank out the whole tool list.
const TolerantListToolsResult = ListToolsResultSchema.extend({
tools: ToolSchema.omit({ outputSchema: true }).array(),
})
export type { GetPromptResult, Prompt, ReadResourceResult, Resource, Tool }
export type ResourceTemplate = ResourceTemplateType
export class NeedsAuthError extends Schema.TaggedError<NeedsAuthError>()("MCP.NeedsAuthError", {
server: Schema.String,
}) {
@@ -52,54 +50,13 @@ export class ConnectError extends Schema.TaggedError<ConnectError>()("MCP.Connec
message: Schema.String,
}) {}
export interface ToolDefinition {
readonly name: string
readonly description: string | undefined
readonly inputSchema: unknown
readonly outputSchema: unknown
}
export interface PromptDefinition {
readonly name: string
readonly description: string | undefined
readonly arguments:
| ReadonlyArray<{
readonly name: string
readonly description: string | undefined
readonly required: boolean | undefined
}>
| undefined
}
export interface PromptMessage {
readonly role: string
readonly content: unknown
}
export interface PromptResult {
readonly messages: ReadonlyArray<PromptMessage>
}
export interface ResourceDefinition {
readonly name: string
readonly uri: string
readonly description: string | undefined
readonly mimeType: string | undefined
}
export interface ResourceTemplateDefinition {
readonly name: string
readonly uriTemplate: string
readonly description: string | undefined
readonly mimeType: string | undefined
}
export type ResourceContentPart =
| { readonly type: "text"; readonly uri: string; readonly text: string; readonly mimeType: string | undefined }
| { readonly type: "blob"; readonly uri: string; readonly blob: string; readonly mimeType: string | undefined }
export interface ReadResourceResult {
readonly contents: ReadonlyArray<ResourceContentPart>
/** A legacy Streamable HTTP server no longer recognizes this connection's session; the lifecycle reconnects. */
export class SessionExpiredError extends Schema.TaggedError<SessionExpiredError>()("MCP.SessionExpiredError", {
server: Schema.String,
}) {
override get message() {
return `MCP server session expired: ${this.server}`
}
}
export type CallToolContent =
@@ -108,7 +65,7 @@ export type CallToolContent =
export interface CallToolResult {
readonly isError: boolean
readonly structured: unknown
readonly structured?: unknown
readonly content: ReadonlyArray<CallToolContent>
}
@@ -122,86 +79,88 @@ export interface ElicitationHandler {
readonly params: ElicitationParams
readonly signal: AbortSignal
}) => Effect.Effect<ElicitationResult, Error>
/**
* Legacy era only: the server announces that a URL-mode elicitation finished out of band. Removed in
* 2026-07-28, where the client learns the outcome by retrying, but legacy servers still send it and
* it lets the form settle without the user re-running the tool.
*/
readonly complete: (input: {
readonly server: string
readonly elicitationID: ElicitRequestURLParams["elicitationId"]
}) => Effect.Effect<void>
}
export interface LogMessage {
readonly level: LoggingMessageNotification["params"]["level"]
readonly logger?: LoggingMessageNotification["params"]["logger"]
readonly data: LoggingMessageNotification["params"]["data"]
}
/** Handle over a connected MCP server that keeps the SDK `Client` out of the rest of core. */
export interface Connection {
/** Server-supplied usage instructions from the initialize result, if any. */
/** True when the connection negotiated revision 2026-07-28 or later; false is the initialize handshake. */
readonly modern: boolean
readonly instructions: string | undefined
/** Lists the server's tools; returns [] when the server doesn't advertise tool support, fails on a transport error. */
readonly tools: () => Effect.Effect<ToolDefinition[], Error>
/** Lists the server's prompts; returns [] when the server doesn't advertise prompt support, fails on a transport error. */
readonly prompts: () => Effect.Effect<PromptDefinition[], Error>
/** Lists the server's resources; returns [] when the server doesn't advertise resource support. */
readonly resources: () => Effect.Effect<ResourceDefinition[], Error>
/** Lists the server's resource templates; returns [] when the server doesn't advertise resource support. */
readonly resourceTemplates: () => Effect.Effect<ResourceTemplateDefinition[], Error>
/** Reads one resource; returns undefined when the server doesn't advertise resource support. */
readonly tools: () => Effect.Effect<Tool[], Error>
readonly prompts: () => Effect.Effect<Prompt[], Error>
readonly resources: () => Effect.Effect<Resource[], Error>
readonly resourceTemplates: () => Effect.Effect<ResourceTemplateType[], Error>
/** Resolves to undefined when the server does not advertise resources. */
readonly readResource: (input: { readonly uri: string }) => Effect.Effect<ReadResourceResult | undefined, Error>
/** Invokes a prompt on the server. Interruption aborts the in-flight request. */
readonly prompt: (input: {
readonly name: string
readonly args?: Record<string, string>
}) => Effect.Effect<PromptResult, Error>
/** Invokes a tool on the server. Interruption aborts the in-flight request. */
}) => Effect.Effect<GetPromptResult, Error>
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. */
readonly onLog: (callback: (message: LogMessage) => void) => void
/** Registers a callback fired when the server announces its tool list changed; no-op if unsupported. */
readonly onSessionExpired: (callback: () => void) => void
readonly onToolsChanged: (callback: () => void) => void
/** Registers a callback fired when the server announces its prompt list changed; no-op if unsupported. */
readonly onPromptsChanged: (callback: () => void) => void
/** Registers a callback fired when the server announces its resource catalog changed. */
readonly onResourcesChanged: (callback: () => void) => void
}
/**
* Connects an MCP server; closing the calling scope tears down the transport and any spawned process.
*
* A stdio server is spawned through the location's `Environment`, so it runs on the same execution
* plane as the location's shell commands rather than always on the host.
* A stdio server is spawned through the location's `Environment`, so it runs wherever the location's
* shell commands run rather than always on the host.
*/
export const connect = Effect.fnUntraced(function* (
server: string,
config: typeof ConfigMCP.Server.Type,
directory: string,
// Only consumed by the remote transport; stdio servers have no auth concept. A provider with no
// stored token (and a no-op redirect) surfaces an UnauthorizedError, which we map to needs_auth.
// Remote only. A provider with no stored token and a no-op redirect ends in UnauthorizedError → needs_auth.
authProvider?: OAuthClientProvider,
elicitation?: ElicitationHandler,
clientInfo: Implementation = { name: "opencode", version: "unknown" },
) {
// The SDK takes list-changed handlers at construction, but consumers register after connect. On a
// modern connection the SDK opens the subscriptions/listen stream behind these itself.
const changed = { tools: () => {}, prompts: () => {}, resources: () => {} }
const listChanged = (key: keyof typeof changed) => ({
autoRefresh: false,
debounceMs: 0,
onChanged: () => changed[key](),
})
const initialize = Effect.fnUntraced(function* (transport: Transport) {
const client = new Client(clientInfo, {
capabilities: {
...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}),
// https://github.com/anomalyco/opencode/issues/2308
// Legacy era only: roots are deprecated as of 2026-07-28 and modern servers cannot request them.
// Some legacy servers refuse to run without one (https://github.com/anomalyco/opencode/issues/2308).
roots: {},
},
versionNegotiation: negotiation(config.protocol),
listChanged: {
tools: listChanged("tools"),
prompts: listChanged("prompts"),
resources: listChanged("resources"),
},
})
client.setRequestHandler(ListRootsRequestSchema, () =>
Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }),
)
client.setRequestHandler("roots/list", () => ({ roots: [{ uri: pathToFileURL(directory).href }] }))
if (elicitation) {
client.setRequestHandler(ElicitRequestSchema, (request, extra) =>
Effect.runPromise(elicitation.create({ server, params: request.params, signal: extra.signal })),
client.setRequestHandler("elicitation/create", (request, ctx) =>
Effect.runPromise(elicitation.create({ server, params: request.params, signal: ctx.mcpReq.signal })),
)
client.setNotificationHandler(ElicitationCompleteNotificationSchema, (notification) =>
client.setNotificationHandler("notifications/elicitation/complete", (notification) =>
Effect.runPromise(elicitation.complete({ server, elicitationID: notification.params.elicitationId })),
)
}
@@ -214,6 +173,28 @@ export const connect = Effect.fnUntraced(function* (
return client
})
// Legacy era only: the transport holds the Mcp-Session-Id the server minted, and the server answering
// it with 404, or with the 400 a freshly restarted single-session server emits, means it no longer
// knows this connection. Modern connections never carry a session id, so a modern 404 for an unknown
// method is not mistaken for expiry. Other 400s pass through untouched.
const session: { transport?: StreamableHTTPClientTransport; expired?: () => void; reported: boolean } = {
reported: false,
}
const failure = (error: unknown) => {
if (!(error instanceof SdkHttpError) || session.transport?.sessionId === undefined) return toError(error)
const expired =
error.status === 404 ||
(error.status === 400 &&
typeof error.data.text === "string" &&
error.data.text.includes("Bad Request: Server not initialized"))
if (!expired) return toError(error)
if (!session.reported) {
session.reported = true
session.expired?.()
}
return new SessionExpiredError({ server })
}
const exit = yield* Effect.gen(function* () {
if (config.type === "local") {
const [command, ...args] = config.command
@@ -233,251 +214,128 @@ export const connect = Effect.fnUntraced(function* (
return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` })
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
const fetch = yield* McpOAuth.loggedFetch({ server, directory })
// Prefer raw tools for our Code Mode without changing the configured URL used for OAuth identity.
// Servers that bundle their own Code Mode (Cloudflare and others) expose raw tools when asked
// with ?codemode=false, which is what our Code Mode wants. The configured URL stays the OAuth identity.
const url = new URL(config.url)
const addedCodemode = config.codemode !== false && !url.searchParams.has("codemode")
if (addedCodemode) url.searchParams.set("codemode", "false")
const open = (url: URL) =>
initialize(
new StreamableHTTPClientTransport(url, {
requestInit: config.headers ? { headers: config.headers } : undefined,
authProvider,
fetch,
}),
)
const open = (url: URL) => {
session.transport = new StreamableHTTPClientTransport(url, {
requestInit: config.headers ? { headers: config.headers } : undefined,
authProvider,
fetch,
})
return initialize(session.transport)
}
return yield* open(url).pipe(
Effect.catch((error) => {
if (!addedCodemode || !(error instanceof StreamableHTTPError) || (error.code !== 400 && error.code !== 404))
if (!addedCodemode || !(error instanceof SdkHttpError) || (error.status !== 400 && error.status !== 404))
return Effect.fail(error)
// Some servers reject unknown query params. Retry once with the user's original URL.
// Servers that reject unknown query params get one retry at the configured URL.
return open(new URL(config.url))
}),
)
}).pipe(Effect.exit)
if (Exit.isSuccess(exit)) {
const client = exit.value
// Closing the client closes the transport, which ends stdin and then kills through the spawner
// handle if the server does not exit cleanly. The process scope remains a final backstop.
yield* Effect.addFinalizer(() => Effect.promise(() => client.close()).pipe(Effect.ignore))
const catalogTimeout = config.timeout?.catalog ?? DEFAULT_CATALOG_TIMEOUT
const executionTimeout = config.timeout?.execution ?? DEFAULT_EXECUTION_TIMEOUT
const catalog = { timeout: config.timeout?.catalog ?? DEFAULT_CATALOG_TIMEOUT }
const execution = config.timeout?.execution ?? DEFAULT_EXECUTION_TIMEOUT
const request = <A>(what: string, run: (signal: AbortSignal) => Promise<A>) =>
Effect.tryPromise({ try: run, catch: failure }).pipe(
Effect.tapError((error) => Effect.logWarning(`failed to ${what}`, { server, error: error.message })),
)
return {
modern: client.getProtocolEra() === "modern",
instructions: client.getInstructions()?.trim() || undefined,
tools: () =>
Effect.gen(function* () {
if (!client.getServerCapabilities()?.tools) return []
const tools = yield* Effect.tryPromise({
try: () =>
paginate(
async (cursor) => {
const params = cursor === undefined ? undefined : { cursor }
try {
return await client.listTools(params, { timeout: catalogTimeout })
} catch (error) {
if (!(error instanceof Error) || !isOutputSchemaError(error)) throw error
return client.request({ method: "tools/list", params }, TolerantListToolsResult, {
timeout: catalogTimeout,
})
}
},
(result) => result.tools,
),
catch: toError,
}).pipe(
Effect.tapError((error) => Effect.logWarning("failed to list MCP tools", { server, error: error.message })),
)
return tools.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
outputSchema: "outputSchema" in tool ? tool.outputSchema : undefined,
}))
}),
request("list MCP tools", () => client.listTools(undefined, catalog)).pipe(Effect.map((r) => r.tools)),
prompts: () =>
Effect.gen(function* () {
if (!client.getServerCapabilities()?.prompts) return []
const prompts = yield* Effect.tryPromise({
try: () =>
paginate(
(cursor) =>
client.listPrompts(cursor === undefined ? undefined : { cursor }, { timeout: catalogTimeout }),
(result) => result.prompts,
),
catch: toError,
}).pipe(
Effect.tapError((error) =>
Effect.logWarning("failed to list MCP prompts", { server, error: error.message }),
),
)
return prompts.map((prompt) => ({
name: prompt.name,
description: prompt.description,
arguments: prompt.arguments?.map((argument) => ({
name: argument.name,
description: argument.description,
required: argument.required,
})),
}))
}),
request("list MCP prompts", () => client.listPrompts(undefined, catalog)).pipe(Effect.map((r) => r.prompts)),
resources: () =>
Effect.gen(function* () {
if (!client.getServerCapabilities()?.resources) return []
const resources = yield* Effect.tryPromise({
try: () =>
paginate(
(cursor) =>
client.listResources(cursor === undefined ? undefined : { cursor }, { timeout: catalogTimeout }),
(result) => result.resources,
),
catch: toError,
}).pipe(
Effect.tapError((error) =>
Effect.logWarning("failed to list MCP resources", { server, error: error.message }),
),
)
return resources.map((resource) => ({
name: resource.name,
uri: resource.uri,
description: resource.description,
mimeType: resource.mimeType,
}))
}),
request("list MCP resources", () => client.listResources(undefined, catalog)).pipe(
Effect.map((r) => r.resources),
),
resourceTemplates: () =>
Effect.gen(function* () {
if (!client.getServerCapabilities()?.resources) return []
const templates = yield* Effect.tryPromise({
try: () =>
paginate(
(cursor) =>
client.listResourceTemplates(cursor === undefined ? undefined : { cursor }, {
timeout: catalogTimeout,
}),
(result) => result.resourceTemplates,
),
catch: toError,
}).pipe(
Effect.tapError((error) =>
Effect.logWarning("failed to list MCP resource templates", { server, error: error.message }),
),
)
return templates.map((template) => ({
name: template.name,
uriTemplate: template.uriTemplate,
description: template.description,
mimeType: template.mimeType,
}))
}),
readResource: (input) =>
Effect.gen(function* () {
if (!client.getServerCapabilities()?.resources) return undefined
const result = yield* Effect.tryPromise({
try: (signal) => client.readResource({ uri: input.uri }, { signal, timeout: executionTimeout }),
catch: toError,
}).pipe(
Effect.tapError((error) =>
Effect.logWarning("failed to read MCP resource", { server, uri: input.uri, error: error.message }),
),
)
return {
contents: result.contents.map(
(part): ResourceContentPart =>
"text" in part
? { type: "text", uri: part.uri, text: part.text, mimeType: part.mimeType }
: { type: "blob", uri: part.uri, blob: part.blob, mimeType: part.mimeType },
),
}
}),
request("list MCP resource templates", () => client.listResourceTemplates(undefined, catalog)).pipe(
Effect.map((r) => r.resourceTemplates),
),
readResource: (input) => {
if (!client.getServerCapabilities()?.resources) return Effect.succeed(undefined)
return request("read MCP resource", (signal) =>
client.readResource({ uri: input.uri }, { signal, timeout: execution }),
)
},
prompt: (input) =>
Effect.tryPromise({
try: (signal) =>
client.getPrompt({ name: input.name, arguments: input.args ?? {} }, { signal, timeout: executionTimeout }),
catch: toError,
}).pipe(
Effect.map((result) => ({
messages: result.messages.map((message) => ({ role: message.role, content: message.content })),
})),
request("get MCP prompt", (signal) =>
client.getPrompt({ name: input.name, arguments: input.args ?? {} }, { signal, timeout: execution }),
),
callTool: (input) =>
Effect.tryPromise({
try: (signal) =>
client.callTool(
{
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: () => {} },
),
catch: toError,
}).pipe(
Effect.map((result) => ({
isError: result.isError === true,
structured: result.structuredContent,
content: result.content.flatMap((part): CallToolContent[] => {
if (part.type === "text") return [{ type: "text", text: part.text }]
if (part.type === "image" || part.type === "audio")
return [{ type: "media", data: part.data, mimeType: part.mimeType }]
if (part.type === "resource_link") return [{ type: "text", text: part.uri }]
if (part.type === "resource") {
const resource = part.resource
if ("text" in resource && typeof resource.text === "string")
return [{ type: "text", text: resource.text }]
if ("blob" in resource && typeof resource.blob === "string" && typeof resource.mimeType === "string")
return [{ type: "media", data: resource.blob, mimeType: resource.mimeType }]
return [{ type: "text", text: resource.uri }]
}
return []
}),
})),
),
request("call MCP tool", (signal) =>
client.callTool(
{
name: input.name,
arguments: input.args ?? {},
...(input.sessionID === undefined ? {} : { _meta: { "ai.opencode/sessionID": input.sessionID } }),
},
// Requesting progress keeps long calls alive under the SDK's timeout; execution is the hard wall.
{ signal, timeout: execution, onprogress: () => {} },
),
).pipe(Effect.map(toCallToolResult)),
onClose: (callback) => {
client.onclose = callback
},
onLog: (callback) => {
client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => callback(notification.params))
onSessionExpired: (callback) => {
session.expired = callback
},
onToolsChanged: (callback) => {
if (!client.getServerCapabilities()?.tools?.listChanged) return
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => callback())
changed.tools = callback
},
onPromptsChanged: (callback) => {
if (!client.getServerCapabilities()?.prompts?.listChanged) return
client.setNotificationHandler(PromptListChangedNotificationSchema, async () => callback())
changed.prompts = callback
},
onResourcesChanged: (callback) => {
if (!client.getServerCapabilities()?.resources?.listChanged) return
client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => callback())
changed.resources = callback
},
} satisfies Connection
}
const error = Cause.squash(exit.cause)
if (error instanceof UnauthorizedError) return yield* new NeedsAuthError({ server })
if (error instanceof UnsupportedProtocolVersionError)
return yield* new ConnectError({
server,
message: `${error.message}; the server supports ${error.supported.join(", ")}. Set "protocol" for this server to one of those or to "legacy".`,
})
return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) })
})
async function paginate<R extends { nextCursor?: string }, T>(
list: (cursor: string | undefined) => Promise<R>,
items: (result: R) => T[],
) {
const collected: T[] = []
const seen = new Set<string>()
let cursor: string | undefined
while (true) {
const result = await list(cursor)
collected.push(...items(result))
if (result.nextCursor === undefined) return collected
// A repeating cursor never terminates; bail instead of hanging the connection forever.
if (seen.has(result.nextCursor)) throw new Error(`MCP list returned duplicate cursor: ${result.nextCursor}`)
seen.add(result.nextCursor)
cursor = result.nextCursor
}
// Absent config is legacy: the SDK sends the plain initialize handshake with no discover probe.
function negotiation(protocol: ConfigMCP.Protocol | undefined): VersionNegotiationOptions | undefined {
if (protocol === undefined || protocol === "legacy") return undefined
if (protocol === "auto") return { mode: "auto" }
return { mode: { pin: protocol } }
}
const isOutputSchemaError = (error: Error) =>
/can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test(
error.message,
)
function toCallToolResult(result: SdkCallToolResult): CallToolResult {
return {
isError: result.isError === true,
structured: result.structuredContent,
content: result.content.flatMap((part): CallToolContent[] => {
if (part.type === "text") return [{ type: "text", text: part.text }]
if (part.type === "image" || part.type === "audio")
return [{ type: "media", data: part.data, mimeType: part.mimeType }]
if (part.type === "resource_link") return [{ type: "text", text: part.uri }]
if (part.type === "resource") {
const resource = part.resource
if ("text" in resource && typeof resource.text === "string") return [{ type: "text", text: resource.text }]
if ("blob" in resource && typeof resource.blob === "string" && typeof resource.mimeType === "string")
return [{ type: "media", data: resource.blob, mimeType: resource.mimeType }]
return [{ type: "text", text: resource.uri }]
}
return []
}),
}
}
+110 -285
View File
@@ -20,70 +20,24 @@ import { State } from "../state.js"
import type { McpClient } from "./client.js"
export const ServerName = Schema.String.pipe(Schema.brand("MCP.ServerName"))
export const PromptsChanged = ephemeral({ type: "mcp.prompts.changed", schema: { server: Schema.String } })
export type ServerName = typeof ServerName.Type
export const PromptsChanged = ephemeral({ type: "mcp.prompts.changed", schema: { server: Schema.String } })
// The status union is a public wire contract, so it lives in @opencode/schema and is re-exported here.
export const Status = Mcp.Status
export type Status = Mcp.Status
export type ServerInfo = Mcp.Server
export class ServerInfo extends Schema.Class<ServerInfo>("MCP.ServerInfo")({
name: ServerName,
status: Status,
integrationID: Integration.ID.pipe(Schema.optional),
}) {}
export interface ServerInstructions {
readonly server: ServerName
readonly instructions: string
}
export class ServerInstructions extends Schema.Class<ServerInstructions>("MCP.ServerInstructions")({
server: ServerName,
instructions: Schema.String,
}) {}
export class Tool extends Schema.Class<Tool>("MCP.Tool")({
server: ServerName,
name: Schema.String,
codemode: Schema.Boolean.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
inputSchema: Schema.Unknown.pipe(Schema.optional),
outputSchema: Schema.Unknown.pipe(Schema.optional),
}) {}
export const ToolResultContent = Schema.Union([
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
Schema.Struct({ type: Schema.Literal("media"), data: Schema.String, mimeType: Schema.String }),
]).pipe(Schema.toTaggedUnion("type"))
export type ToolResultContent = typeof ToolResultContent.Type
export class ToolResult extends Schema.Class<ToolResult>("MCP.ToolResult")({
server: ServerName,
tool: Schema.String,
isError: Schema.Boolean,
structured: Schema.Unknown.pipe(Schema.optional),
content: Schema.Array(ToolResultContent),
}) {}
export class PromptArgument extends Schema.Class<PromptArgument>("MCP.PromptArgument")({
name: Schema.String,
description: Schema.String.pipe(Schema.optional),
required: Schema.Boolean.pipe(Schema.optional),
}) {}
export class Prompt extends Schema.Class<Prompt>("MCP.Prompt")({
server: ServerName,
name: Schema.String,
description: Schema.String.pipe(Schema.optional),
arguments: Schema.Array(PromptArgument).pipe(Schema.optional),
}) {}
export class PromptMessage extends Schema.Class<PromptMessage>("MCP.PromptMessage")({
role: Schema.String,
content: Schema.Unknown,
}) {}
export class PromptResult extends Schema.Class<PromptResult>("MCP.PromptResult")({
server: ServerName,
name: Schema.String,
messages: Schema.Array(PromptMessage),
}) {}
/** SDK tool definition tagged with the server that owns it. */
export type Tool = McpClient.Tool & { readonly server: ServerName; readonly codemode?: boolean }
export type ToolResultContent = McpClient.CallToolContent
export type ToolResult = McpClient.CallToolResult & { readonly server: ServerName; readonly tool: string }
export type Prompt = McpClient.Prompt & { readonly server: ServerName }
export type PromptResult = McpClient.GetPromptResult & { readonly server: ServerName; readonly name: string }
export const Resource = Mcp.Resource
export type Resource = Mcp.Resource
@@ -195,12 +149,11 @@ export const layer = (options?: Options) =>
const root = yield* Effect.scope
const fork = yield* FiberSet.makeRuntime<never, void, never>()
// Materialized definitions and live connections are kept separate so operational additions
// survive unrelated definition reloads.
const entries = new Map<ServerName, ServerEntry>()
// Serializes lifecycle operations per server. Anything taking this lock from a connection
// callback must stay forked: lifecycle operations close scopes while holding it, firing onClose.
const locks = KeyedMutex.makeUnsafe<ServerName>()
// Legacy era only: pending URL-mode elicitation forms, settled by notifications/elicitation/complete.
const urlElicitations = new Map<string, Form.ID>()
// Register every remote server as an OAuth integration so credentials live in the global store
@@ -238,7 +191,9 @@ export const layer = (options?: Options) =>
authorize: () =>
Effect.gen(function* () {
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
return yield* McpOAuth.authorize({ name, config: remote, methodID })
return yield* McpOAuth.authorize({ name, config: remote, integrationID, methodID }).pipe(
Effect.provideService(Credential.Service, credentials),
)
}),
})
})
@@ -251,118 +206,12 @@ export const layer = (options?: Options) =>
return { name, entry }
})
// Builds the connect-time auth provider for a remote OAuth-integration server. The SDK presents and
// refreshes stored tokens, persisting refreshes back to the same credential row. The provider never
// opens a browser, so an auth-gated connect ends in UnauthorizedError -> needs_auth rather than a redirect.
const connectProvider = Effect.fnUntraced(function* (entry: ServerEntry) {
if (entry.config.type !== "remote" || !entry.integrationID) return undefined
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
const remote = entry.config
const oauth = remote.oauth || undefined
const run = Effect.runPromiseWith(yield* Effect.context())
const base = {
redirectUrl: oauth?.redirect_uri ?? "http://127.0.0.1/callback",
scope: oauth?.scope,
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
// No browser during connect: an auth-gated server surfaces needs_auth instead of opening a browser.
onRedirect: () => run(Effect.logInfo("mcp oauth authorization required")),
}
const found = (yield* credentials.list(entry.integrationID)).at(-1)
if (!found || found.value.type !== "oauth") {
// No stored credential yet: an empty in-memory store still lets the SDK run the auth handshake, which
// ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
// a raw HTTP error, hiding the auth requirement behind a generic failed status. Anonymous servers are
// unaffected: tokens() returns undefined, so no auth header is sent and the SDK never calls auth().
yield* Effect.logInfo("mcp oauth credential unavailable", {
integrationID: entry.integrationID,
reason: found ? "not_oauth" : "missing",
})
return McpOAuth.provider({ ...base, store: McpOAuth.memoryStore() })
}
const credentialID = found.id
const methodID = found.value.methodID
const fields = { credentialID, integrationID: entry.integrationID }
yield* Effect.logInfo("mcp oauth credential loaded", {
...fields,
hasRefreshToken: Boolean(found.value.refresh),
hasClientInformation: Boolean(McpOAuth.clientFromCredential(found.value)),
expiresAt: found.value.expires,
expired: found.value.expires !== 0 && found.value.expires <= Date.now(),
})
// Tracks the refresh token this provider last presented, so invalidate can tell whether the SDK
// rejected the currently-stored credential or a snapshot another connection has already rotated past.
let presented = found.value.refresh
const readOAuthCredential = async () => {
const stored = await run(credentials.get(credentialID))
return stored?.value.type === "oauth" ? stored.value : undefined
}
return McpOAuth.provider({
...base,
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth — but only if it is
// still the stored one. Rotating servers hand out a fresh refresh token per use, so a concurrent
// connection may have already replaced ours; deleting then would discard the newer valid credential and
// strand every connection in needs_auth until a manual re-auth. Credential deletion notifies all locations;
// reconnects remain serialized by the server lock.
invalidate: async (scope) => {
if (scope === "verifier" || scope === "discovery") {
await run(
Effect.logDebug("mcp oauth invalidation skipped", { ...fields, scope, reason: "not_credentials" }),
)
return
}
const oauth = await readOAuthCredential()
if (!oauth || oauth.refresh !== presented) {
await run(
Effect.logInfo("mcp oauth invalidation skipped", {
...fields,
scope,
reason: oauth ? "token_rotated" : "credential_missing",
}),
)
return
}
await run(Effect.logWarning("mcp oauth credential invalidation requested", { ...fields, scope }))
await run(credentials.remove(credentialID))
},
// Always read the latest stored tokens instead of caching at connect time: with refresh-token rotation,
// a cached snapshot goes stale the moment another connection refreshes, and re-presenting the consumed
// token fails with invalid_grant.
store: {
tokens: async () => {
const oauth = await readOAuthCredential()
if (!oauth) return undefined
presented = oauth.refresh
return McpOAuth.toTokens(oauth)
},
saveTokens: async (tokens) => {
const previous = await readOAuthCredential()
const value = McpOAuth.toCredential({
methodID,
serverUrl: remote.url,
tokens,
client: previous ? McpOAuth.clientFromCredential(previous) : undefined,
})
presented = value.refresh
await run(
Effect.logInfo("mcp oauth tokens received", {
...fields,
credentialPresent: Boolean(previous),
refreshRotated: Boolean(previous && previous.refresh !== value.refresh),
hasRefreshToken: Boolean(value.refresh),
expiresAt: value.expires,
}),
)
await run(credentials.update(credentialID, { value }))
},
clientInformation: async () => {
const oauth = await readOAuthCredential()
return oauth ? McpOAuth.clientFromCredential(oauth) : undefined
},
saveClientInformation: async () => {},
codeVerifier: async () => undefined,
saveCodeVerifier: async () => {},
},
})
return yield* McpOAuth.connectProvider({ config: entry.config, integrationID: entry.integrationID }).pipe(
Effect.provideService(Credential.Service, credentials),
)
})
const elicitation = {
@@ -374,8 +223,11 @@ export const layer = (options?: Options) =>
Effect.gen(function* () {
if (input.params.mode === "url") {
const formID = Form.ID.create()
const key = input.server + "\u0000" + input.params.elicitationId
urlElicitations.set(key, formID)
// Legacy only: 2026-07-28 has no elicitationId and no completion notification, so the form
// settles when the user confirms and the SDK retries the tool call itself.
const elicitationID: string | undefined = input.params.elicitationId
const key = elicitationID === undefined ? undefined : input.server + "\u0000" + elicitationID
if (key) urlElicitations.set(key, formID)
return yield* forms
.ask({
id: formID,
@@ -384,14 +236,14 @@ export const layer = (options?: Options) =>
metadata: {
kind: "mcp-elicitation",
server: input.server,
elicitationID: input.params.elicitationId,
...(elicitationID === undefined ? {} : { elicitationID }),
message: input.params.message,
},
fields: [{ key: URL_ELICITATION_FIELD_KEY, type: "external", url: input.params.url }],
})
.pipe(
Effect.raceFirst(waitForAbort(input.signal)),
Effect.ensuring(Effect.sync(() => urlElicitations.delete(key))),
Effect.ensuring(Effect.sync(() => key && urlElicitations.delete(key))),
Effect.map(
(state): McpClient.ElicitationResult => ({
action: state.status === "answered" ? "accept" : "cancel",
@@ -435,61 +287,24 @@ export const layer = (options?: Options) =>
}),
} satisfies McpClient.ElicitationHandler
const toTool = (server: ServerName, entry: ServerEntry, def: McpClient.ToolDefinition) =>
new Tool({
server,
name: def.name,
codemode: entry.config.codemode,
description: def.description,
inputSchema: def.inputSchema,
outputSchema: def.outputSchema,
})
const toPrompt = (server: ServerName, def: McpClient.PromptDefinition) =>
new Prompt({
server,
name: def.name,
description: def.description,
arguments: def.arguments?.map(
(argument) =>
new PromptArgument({
name: argument.name,
description: argument.description,
required: argument.required,
}),
),
})
const toResource = (server: ServerName, def: McpClient.ResourceDefinition) =>
Resource.make({
server,
name: def.name,
uri: def.uri,
description: def.description,
mimeType: def.mimeType,
})
const toResourceTemplate = (server: ServerName, def: McpClient.ResourceTemplateDefinition) =>
ResourceTemplate.make({
server,
name: def.name,
uriTemplate: def.uriTemplate,
description: def.description,
mimeType: def.mimeType,
})
const toTool = (server: ServerName, entry: ServerEntry, tool: McpClient.Tool): Tool => ({
...tool,
server,
...(entry.config.codemode === undefined ? {} : { codemode: entry.config.codemode }),
})
const refreshTools = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) =>
connection.tools().pipe(
Effect.map((defs) => {
entry.tools = defs.map((def) => toTool(name, entry, def))
Effect.map((tools) => {
entry.tools = tools.map((tool) => toTool(name, entry, tool))
}),
)
const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) =>
connection.prompts().pipe(
Effect.orElseSucceed(() => []),
Effect.map((defs) => {
entry.prompts = defs.map((def) => toPrompt(name, def))
Effect.map((prompts) => {
entry.prompts = prompts.map((prompt): Prompt => ({ ...prompt, server: name }))
}),
Effect.andThen(bus.publish(PromptsChanged, { server: name })),
)
@@ -506,6 +321,35 @@ export const layer = (options?: Options) =>
),
)
// Re-establishes a server whose HTTP session the server dropped, unless another path already
// replaced the connection while this one waited for the lock.
const recover = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) =>
Effect.gen(function* () {
if (entry.client !== connection) return
yield* Effect.logInfo("mcp session expired, reconnecting", { server: name })
yield* stopServer(name, entry)
yield* startServer(name, entry)
}).pipe(locks.withLock(name))
// Runs a request against the live connection and, if that request observed a session expiry,
// reconnects and runs it once more against the replacement. Any other failure passes through.
const recovering = <A, E extends Error>(
name: ServerName,
entry: ServerEntry,
connection: McpClient.Connection,
run: (connection: McpClient.Connection) => Effect.Effect<A, E>,
) =>
run(connection).pipe(
Effect.catchIf(
// The client module is loaded lazily, so match the tagged error by tag rather than class.
(error) => "_tag" in error && error._tag === "MCP.SessionExpiredError",
(error) =>
recover(name, entry, connection).pipe(
Effect.flatMap(() => (entry.client ? run(entry.client) : Effect.fail(error))),
),
),
)
const watch = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) => {
const live = whenLive(name, entry, connection)
connection.onClose(() =>
@@ -517,7 +361,9 @@ export const layer = (options?: Options) =>
}),
),
)
connection.onLog((message) => fork(serverLog(name, message)))
// Background refreshes (list-changed) can observe the expiry too; they do not retry, so
// reconnect here for them. Foreground calls reconnect through `recovering`.
connection.onSessionExpired(() => fork(recover(name, entry, connection)))
connection.onToolsChanged(() =>
live(
refreshTools(name, entry, connection).pipe(
@@ -529,24 +375,6 @@ export const layer = (options?: Options) =>
connection.onResourcesChanged(() => live(bus.publish(McpEvent.ResourcesChanged, { server: name })))
}
const serverLog = (server: ServerName, message: McpClient.LogMessage) => {
const fields = { server, logger: message.logger, level: message.level, data: message.data }
switch (message.level) {
case "debug":
return Effect.logDebug("MCP server log", fields)
case "info":
case "notice":
return Effect.logInfo("MCP server log", fields)
case "warning":
return Effect.logWarning("MCP server log", fields)
case "error":
case "critical":
case "alert":
case "emergency":
return Effect.logError("MCP server log", fields)
}
}
const startServer = (name: ServerName, entry: ServerEntry) =>
Effect.gen(function* () {
// Announce the handshake so connect() and credential reconnects don't show a stale
@@ -575,14 +403,12 @@ export const layer = (options?: Options) =>
)
if (Exit.isSuccess(result)) {
entry.client = result.value.connection
entry.tools = result.value.tools.map((def) => toTool(name, entry, def))
entry.tools = result.value.tools.map((tool) => toTool(name, entry, tool))
entry.prompts = []
entry.status = { status: "connected" }
watch(name, entry, result.value.connection)
yield* Effect.logInfo("mcp connected", { server: name, tools: entry.tools.length })
// Announce the new tool set so the tool registry registers it. A server that finishes connecting
// after the initial registration sweep and emits no list-changed notification would otherwise
// stay invisible to the model.
// The tool registry reads on this event; a late-connecting server has no other way to appear.
yield* bus.publish(McpEvent.ToolsChanged, { server: name })
yield* bus.publish(McpEvent.ResourcesChanged, { server: name })
yield* bus.publish(McpEvent.StatusChanged, { server: name })
@@ -747,20 +573,13 @@ export const layer = (options?: Options) =>
notify: () => State.reconcile(root, fork, () => reconcileLock.withPermit(reconcile())),
})
// Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
const whenAllReady = Effect.suspend(() =>
Effect.forEach(Array.from(entries.values()), (entry) => entry.startup.await, {
concurrency: "unbounded",
discard: true,
}),
)
return Service.of({
transform: state.transform,
reload: state.reload,
servers: Effect.fn("MCP.servers")(function* () {
return Array.from(entries)
.toSorted(([a], [b]) => a.localeCompare(b))
.map(([name, entry]) => new ServerInfo({ name, status: entry.status, integrationID: entry.integrationID }))
.map(([name, entry]): ServerInfo => ({ name, status: entry.status, integrationID: entry.integrationID }))
}),
add: Effect.fn("MCP.add")(function* (server, config) {
const name = ServerName.make(server)
@@ -790,8 +609,8 @@ export const layer = (options?: Options) =>
overrides.set(name, false)
yield* state.reload()
}),
// Reads report what is connected now; servers still starting contribute once they publish a change.
tools: Effect.fn("MCP.tools")(function* () {
yield* whenAllReady
return Array.from(entries.values())
.flatMap((entry) => entry.tools ?? [])
.toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name))
@@ -805,28 +624,21 @@ export const layer = (options?: Options) =>
tool: input.name,
message: "MCP server is not connected",
})
const result = yield* target.entry.client
.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 }),
),
)
return new ToolResult({
server: target.name,
tool: input.name,
isError: result.isError,
structured: result.structured,
content: result.content,
})
const result = yield* recovering(target.name, target.entry, target.entry.client, (connection) =>
connection.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 }),
),
)
return { ...result, server: target.name, tool: input.name }
}),
instructions: Effect.fn("MCP.instructions")(function* () {
yield* whenAllReady
return Array.from(entries)
.flatMap(([server, entry]) => {
const instructions = entry.client?.instructions
if (!instructions) return []
return [new ServerInstructions({ server, instructions })]
return [{ server, instructions }]
})
.toSorted((a, b) => a.server.localeCompare(b.server))
}),
@@ -839,20 +651,13 @@ export const layer = (options?: Options) =>
const target = yield* requireServer(input.server)
yield* target.entry.startup.await
if (!target.entry.client) return undefined
const result = yield* target.entry.client
.prompt({ name: input.name, args: input.args })
.pipe(Effect.orElseSucceed(() => undefined))
const result = yield* recovering(target.name, target.entry, target.entry.client, (connection) =>
connection.prompt({ name: input.name, args: input.args }),
).pipe(Effect.orElseSucceed(() => undefined))
if (!result) return undefined
return new PromptResult({
server: target.name,
name: input.name,
messages: result.messages.map(
(message) => new PromptMessage({ role: message.role, content: message.content }),
),
})
return { ...result, server: target.name, name: input.name }
}),
resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () {
yield* whenAllReady
const catalogs = yield* Effect.forEach(
Array.from(entries),
([name, entry]) => {
@@ -865,8 +670,24 @@ export const layer = (options?: Options) =>
{ concurrency: "unbounded" },
).pipe(
Effect.map((catalog) => ({
resources: catalog.resources.map((def) => toResource(name, def)),
templates: catalog.templates.map((def) => toResourceTemplate(name, def)),
resources: catalog.resources.map((resource) =>
Resource.make({
server: name,
name: resource.name,
uri: resource.uri,
description: resource.description,
mimeType: resource.mimeType,
}),
),
templates: catalog.templates.map((template) =>
ResourceTemplate.make({
server: name,
name: template.name,
uriTemplate: template.uriTemplate,
description: template.description,
mimeType: template.mimeType,
}),
),
})),
)
},
@@ -893,14 +714,18 @@ export const layer = (options?: Options) =>
const target = yield* requireServer(input.server)
yield* target.entry.startup.await
if (!target.entry.client) return undefined
const result = yield* target.entry.client
.readResource({ uri: input.uri })
.pipe(Effect.orElseSucceed(() => undefined))
const result = yield* recovering(target.name, target.entry, target.entry.client, (connection) =>
connection.readResource({ uri: input.uri }),
).pipe(Effect.orElseSucceed(() => undefined))
if (!result) return undefined
return ResourceContent.make({
server: target.name,
uri: input.uri,
contents: result.contents,
contents: result.contents.map((part) =>
"text" in part
? { type: "text", uri: part.uri, text: part.text, mimeType: part.mimeType }
: { type: "blob", uri: part.uri, blob: part.blob, mimeType: part.mimeType },
),
})
}),
})
+213 -98
View File
@@ -2,27 +2,53 @@ export * as McpOAuth from "./oauth.js"
import {
auth,
LATEST_PROTOCOL_VERSION,
checkResourceAllowed,
discoverOAuthServerInfo,
extractWWWAuthenticateParams,
parseErrorResponse,
UnauthorizedError,
type FetchLike,
type OAuthClientProvider,
type OAuthServerInfo,
} from "@modelcontextprotocol/sdk/client/auth.js"
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"
type OAuthDiscoveryState,
type StoredOAuthClientInformation,
type StoredOAuthTokens,
} from "@modelcontextprotocol/client"
import { Cause, Deferred, Effect } from "effect"
import { Credential } from "@opencode/schema/credential"
import { ConfigMCP } from "@opencode/schema/config/mcp"
import { Credential } from "../credential.js"
import { OauthCallbackPage } from "../oauth/page.js"
import type { Integration } from "../integration.js"
import { ErrorSummary } from "../util/error-summary.js"
/**
* opencode's OAuth Client ID Metadata Document. Authorization servers that support CIMD accept this URL as the
* client_id and fetch it to learn our name and redirect URIs, so no per-server dynamic registration is needed.
*/
/** Client ID Metadata Document: servers that support CIMD accept this URL as the client_id without registration. */
export const CLIENT_METADATA_URL = "https://opencode.ai/oauth/opencode/client.json"
/** Observe OAuth failures before the SDK handles them by invalidating credentials or redirecting. */
// Refresh tokens rotate, so concurrent refreshes of the same token share one request or the second gets invalid_grant.
const refreshes = new Map<string, ReturnType<FetchLike>>()
const refreshKey = (url: string | URL, init: RequestInit | undefined) => {
if (!(init?.body instanceof URLSearchParams) || init.body.get("grant_type") !== "refresh_token") return undefined
return [String(url), init.body.get("client_id") ?? "", init.body.get("refresh_token") ?? ""].join("\u0000")
}
// Bun's and Node's fetch types both apply here and the SDK's FetchLike wants Bun's Response, so the
// overload is picked by annotation and clone() is pinned to the type it was called on.
const base: FetchLike = fetch
const share = (pending: ReturnType<FetchLike>) => pending.then((response) => response.clone() as typeof response)
const send: FetchLike = (url, init) => {
const key = refreshKey(url, init)
if (key === undefined) return base(url, init)
const current = refreshes.get(key)
if (current) return share(current)
const pending = base(url, init).finally(() => {
if (refreshes.get(key) === pending) refreshes.delete(key)
})
refreshes.set(key, pending)
return share(pending)
}
export const loggedFetch = (fields: { readonly server: string; readonly directory?: string }) =>
Effect.gen(function* () {
const run = Effect.runPromiseWith(yield* Effect.context())
@@ -33,15 +59,17 @@ export const loggedFetch = (fields: { readonly server: string; readonly director
return run(
Effect.gen(function* () {
if (operation) yield* Effect.logInfo("mcp oauth request started")
const response = yield* Effect.tryPromise({ try: () => fetch(url, init), catch: (error) => error })
const response = yield* Effect.tryPromise({ try: () => send(url, init), catch: (error) => error })
const result = { status: response.status, durationMs: Date.now() - started }
if (operation && !response.ok) {
// Only retain the SDK's standard error code. Descriptions and raw bodies can echo credentials.
const error = yield* Effect.tryPromise(async () => parseErrorResponse(await response.clone().text())).pipe(
Effect.map((error) => error.errorCode),
Effect.orElseSucceed(() => "unreadable_response"),
Effect.orElseSucceed(() => undefined),
)
yield* Effect.logWarning("mcp oauth request rejected", { ...result, error })
yield* Effect.logWarning("mcp oauth request rejected", {
...result,
error: error?.code,
message: error?.message,
})
}
if (operation && response.ok) {
yield* Effect.logInfo("mcp oauth request succeeded", result)
@@ -71,71 +99,79 @@ export const loggedFetch = (fields: { readonly server: string; readonly director
return request
})
/** Persists the OAuth artifacts for one MCP server session: DCR client info, PKCE verifier, and tokens. */
export interface Store {
readonly tokens: () => Promise<OAuthTokens | undefined>
readonly saveTokens: (tokens: OAuthTokens) => Promise<void>
readonly clientInformation: () => Promise<OAuthClientInformationMixed | undefined>
readonly saveClientInformation: (info: OAuthClientInformationMixed) => Promise<void>
readonly tokens: () => Promise<StoredOAuthTokens | undefined>
readonly saveTokens: (tokens: StoredOAuthTokens) => Promise<void>
readonly clientInformation: () => Promise<StoredOAuthClientInformation | undefined>
readonly saveClientInformation: (info: StoredOAuthClientInformation) => Promise<void>
readonly codeVerifier: () => Promise<string | undefined>
readonly saveCodeVerifier: (verifier: string) => Promise<void>
}
export interface Options {
/** Loopback URL the authorization server redirects back to after the user approves. */
readonly redirectUrl: string
/** Space-delimited OAuth scopes to request when the server requires specific ones. */
readonly scope?: string
/** CSRF state embedded in the authorization request; required by the spec and enforced by some servers.
* The caller is responsible for validating the value echoed back to the redirect. */
readonly state?: string
/** Statically pre-registered client credentials from config; when set, the SDK skips dynamic registration. */
readonly client?: { readonly id: string; readonly secret?: string }
/** Use opencode's Client ID Metadata Document as the client_id instead of registering dynamically. */
readonly clientMetadataUrl?: string
/** Pre-fetched authorization server discovery so the SDK does not repeat it. */
readonly discovery?: OAuthServerInfo
/** Invoked by the SDK to drop credentials it has determined are invalid (e.g. a rejected refresh token). */
readonly invalidate?: (scope: "all" | "client" | "tokens" | "verifier" | "discovery") => void | Promise<void>
/** Receives the authorization URL so the caller can open a browser and capture the eventual code. */
readonly onRedirect: (url: URL) => void | Promise<void>
readonly config: typeof ConfigMCP.Remote.Type
readonly store: Store
/** Absent on connect: the provider then refuses to register a client or redirect, ending in needs_auth. */
readonly redirect?: {
readonly url: string
readonly state: string
readonly open: (url: URL) => void | Promise<void>
}
readonly clientMetadataUrl?: string
readonly discovery?: OAuthDiscoveryState
readonly invalidate?: OAuthClientProvider["invalidateCredentials"]
}
/**
* Builds the MCP SDK's OAuthClientProvider. The SDK drives dynamic client registration, PKCE, and
* token refresh through these callbacks; we only persist whatever it hands back via `store`.
*/
export const provider = (options: Options): OAuthClientProvider => {
const state = options.state
const client = options.client
const oauth = options.config.oauth || undefined
const client = oauth?.client_id ? { client_id: oauth.client_id, client_secret: oauth.client_secret } : undefined
const redirect = options.redirect
// A missing redirectUrl selects the client-credentials grant in the SDK, so connect still names one.
const redirectUrl = redirect?.url ?? oauth?.redirect_uri ?? "http://127.0.0.1/callback"
const refuse = (what: string) => new UnauthorizedError(`MCP server "${options.config.url}" requires ${what}`)
const identity = new URL(options.config.url)
identity.hash = ""
let discovery: OAuthDiscoveryState | undefined = options.discovery
return {
redirectUrl: options.redirectUrl,
redirectUrl,
discoveryState: () => discovery,
saveDiscoveryState: (state) => {
discovery = state
},
// The SDK sends no RFC 8707 resource when the server publishes no resource metadata; some
// authorization servers require one, so fall back to the configured URL.
validateResourceURL: async (_serverUrl, resource) => {
if (!resource) return identity
if (!checkResourceAllowed({ requestedResource: identity, configuredResource: resource }))
throw new Error(`Protected resource ${resource} does not cover ${identity}`)
return new URL(resource)
},
...(options.clientMetadataUrl ? { clientMetadataUrl: options.clientMetadataUrl } : {}),
...(options.discovery ? { discoveryState: () => options.discovery } : {}),
...(redirect ? { state: () => redirect.state } : {}),
clientMetadata: {
redirect_uris: [options.redirectUrl],
redirect_uris: [redirectUrl],
client_name: "opencode",
client_uri: "https://opencode.ai",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: client?.secret ? "client_secret_post" : "none",
...(options.scope ? { scope: options.scope } : {}),
token_endpoint_auth_method: client?.client_secret ? "client_secret_post" : "none",
...(oauth?.scope ? { scope: oauth.scope } : {}),
},
clientInformation: async () => {
if (client) return client
const stored = await options.store.clientInformation()
if (!stored && !redirect) throw refuse("a login before it can register a client")
return stored
},
// Only advertise state when the caller supplied one (the interactive flow); the connect-time
// provider has no redirect to validate, so it omits it.
...(state !== undefined ? { state: () => state } : {}),
// Static client config short-circuits dynamic registration; otherwise the SDK registers and we persist.
clientInformation: () =>
client ? { client_id: client.id, client_secret: client.secret } : options.store.clientInformation(),
saveClientInformation: (info) => options.store.saveClientInformation(info),
tokens: () => options.store.tokens(),
saveTokens: (tokens) => options.store.saveTokens(tokens),
redirectToAuthorization: (url) => options.onRedirect(url),
redirectToAuthorization: (url) => {
if (!redirect) throw refuse("user authorization")
return redirect.open(url)
},
...(options.invalidate ? { invalidateCredentials: options.invalidate } : {}),
saveCodeVerifier: (verifier) => options.store.saveCodeVerifier(verifier),
// The SDK only reads the verifier back after saving one earlier in the same flow; a miss means
// the flow was resumed without its session state, which the SDK surfaces as an auth failure.
codeVerifier: async () => {
const verifier = await options.store.codeVerifier()
if (!verifier) throw new Error("Missing PKCE code verifier for MCP OAuth flow")
@@ -144,10 +180,9 @@ export const provider = (options: Options): OAuthClientProvider => {
}
}
/** A Store that keeps OAuth artifacts in memory for the duration of one interactive login attempt. */
export const memoryStore = (): Store => {
let tokens: OAuthTokens | undefined
let client: OAuthClientInformationMixed | undefined
let tokens: StoredOAuthTokens | undefined
let client: StoredOAuthClientInformation | undefined
let verifier: string | undefined
return {
tokens: async () => tokens,
@@ -165,34 +200,32 @@ export const memoryStore = (): Store => {
}
}
/** Reads the dynamically-registered client info we stash in a credential's metadata, for token refresh. */
export const clientFromCredential = (credential: Credential.OAuth) =>
credential.metadata?.client as OAuthClientInformationMixed | undefined
credential.metadata?.client as StoredOAuthClientInformation | undefined
/** Folds SDK tokens (plus DCR client info and the server URL) into a storable credential. */
export const toCredential = (input: {
readonly methodID: Integration.MethodID
readonly serverUrl: string
readonly tokens: OAuthTokens
readonly client: OAuthClientInformationMixed | undefined
readonly tokens: StoredOAuthTokens
readonly client: StoredOAuthClientInformation | undefined
}) =>
Credential.OAuth.make({
type: "oauth",
methodID: input.methodID,
access: input.tokens.access_token,
refresh: input.tokens.refresh_token ?? "",
// 0 marks an unknown/non-expiring token; toTokens then omits expires_in so the SDK won't force a refresh.
// 0 is non-expiring; toTokens then omits expires_in so the SDK does not force a refresh.
expires: input.tokens.expires_in ? Date.now() + input.tokens.expires_in * 1000 : 0,
metadata: {
serverUrl: input.serverUrl,
tokenType: input.tokens.token_type,
...(input.tokens.scope ? { scope: input.tokens.scope } : {}),
...(input.tokens.issuer ? { issuer: input.tokens.issuer } : {}),
...(input.client ? { client: input.client } : {}),
},
})
/** Reconstructs SDK tokens from a stored credential so the connect-time provider can present them. */
export const toTokens = (credential: Credential.OAuth): OAuthTokens => {
export const toTokens = (credential: Credential.OAuth): StoredOAuthTokens => {
const metadata = credential.metadata ?? {}
return {
access_token: credential.access,
@@ -200,17 +233,72 @@ export const toTokens = (credential: Credential.OAuth): OAuthTokens => {
...(credential.refresh ? { refresh_token: credential.refresh } : {}),
...(credential.expires ? { expires_in: Math.max(0, Math.floor((credential.expires - Date.now()) / 1000)) } : {}),
...(typeof metadata.scope === "string" ? { scope: metadata.scope } : {}),
...(typeof metadata.issuer === "string" ? { issuer: metadata.issuer } : {}),
}
}
/**
* Runs the interactive OAuth login for one remote MCP server. Stands up a loopback callback server,
* lets the SDK drive DCR + PKCE to produce an authorization URL, and returns an attempt whose callback
* exchanges the redirect code for a storable credential. Scoped: the callback server closes with the scope.
*/
export const connectProvider = Effect.fnUntraced(function* (input: {
readonly config: typeof ConfigMCP.Remote.Type
readonly integrationID: Integration.ID
}) {
const credentials = yield* Credential.Service
const run = Effect.runPromiseWith(yield* Effect.context())
const found = (yield* credentials.list(input.integrationID)).at(-1)
if (!found || found.value.type !== "oauth") return provider({ config: input.config, store: memoryStore() })
const id = found.id
const methodID = found.value.methodID
const read = async () => {
const stored = await run(credentials.get(id))
return stored?.value.type === "oauth" ? stored.value : undefined
}
// Refresh tokens rotate and the row is shared across connections: only drop it while it still holds ours.
let presented = found.value.refresh
return provider({
config: input.config,
invalidate: async (scope) => {
if (scope === "verifier" || scope === "discovery") return
const oauth = await read()
if (!oauth || oauth.refresh !== presented) return
await run(Effect.logWarning("mcp oauth credential invalidated", { credentialID: id, scope }))
await run(credentials.remove(id))
},
store: {
tokens: async () => {
const oauth = await read()
if (!oauth) return undefined
presented = oauth.refresh
return toTokens(oauth)
},
saveTokens: async (tokens) => {
const previous = await read()
const value = toCredential({
methodID,
serverUrl: input.config.url,
tokens,
client: previous ? clientFromCredential(previous) : undefined,
})
presented = value.refresh
await run(credentials.update(id, { value }))
},
clientInformation: async () => {
const oauth = await read()
return oauth ? clientFromCredential(oauth) : undefined
},
saveClientInformation: async (client) => {
const oauth = await read()
if (!oauth) return
await run(credentials.update(id, { value: { ...oauth, metadata: { ...oauth.metadata, client } } }))
},
codeVerifier: async () => undefined,
saveCodeVerifier: async () => {},
},
})
})
export const authorize = (input: {
readonly name: string
readonly config: typeof ConfigMCP.Remote.Type
readonly integrationID: Integration.ID
readonly methodID: Integration.MethodID
}) =>
Effect.gen(function* () {
@@ -218,11 +306,18 @@ export const authorize = (input: {
const context = yield* Effect.context()
const run = Effect.runPromiseWith(context)
const runFork = Effect.runForkWith(context)
const credentials = yield* Credential.Service
const fetchFn = yield* loggedFetch({ server: input.name }).pipe(Effect.annotateLogs(fields))
yield* Effect.logInfo("mcp oauth authorization started", fields)
const oauth = input.config.oauth || undefined
const store = memoryStore()
const code = yield* Deferred.make<string, Error>()
// Reuse the client registered by an earlier login; the SDK discards it if the issuer changed.
const previous = (yield* credentials.list(input.integrationID)).at(-1)?.value
if (previous?.type === "oauth") {
const client = clientFromCredential(previous)
if (client) yield* Effect.promise(() => store.saveClientInformation(client))
}
const code = yield* Deferred.make<{ code: string; iss: string | undefined }, Error>()
const redirect = oauth?.redirect_uri ? new URL(oauth.redirect_uri) : undefined
const redirectPath = redirect?.pathname ?? "/callback"
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
@@ -244,18 +339,14 @@ export const authorize = (input: {
}
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
if (error) return fail(error, "authorization_error")
// Reject a redirect whose state does not match what we issued: this is the CSRF defense the
// state parameter exists for, so an attacker can't inject their own authorization code.
if (url.searchParams.get("state") !== state) return fail("OAuth state mismatch", "state_mismatch")
const value = url.searchParams.get("code")
if (!value) return fail("Missing authorization code", "missing_code")
Effect.runFork(Deferred.succeed(code, value))
Effect.runFork(Deferred.succeed(code, { code: value, iss: url.searchParams.get("iss") ?? undefined }))
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: input.name }))
})
// Bind the port the redirect will actually arrive on: an explicit callback_port wins, else the port
// pinned by redirect_uri, else an ephemeral port. Binding ephemerally while redirect_uri names a fixed
// port would send the browser somewhere nothing is listening, hanging the attempt until it expires.
// callback_port, else the port pinned by redirect_uri, else ephemeral; a mismatch strands the browser.
const redirectPort = Number(redirect?.port) || undefined
const port = yield* Effect.callback<number, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
@@ -270,12 +361,34 @@ export const authorize = (input: {
})
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
// Discover the authorization server up front so we can decide how to identify ourselves. CIMD only works
// when the server advertises it, accepts public clients (our document declares no client secret), and the
// redirect is our own loopback URL (a user-configured redirect_uri is not in the published document).
// A configured client_id is pre-registered and always wins.
// The server's 401 names where its resource metadata lives and which scopes it wants; without it
// discovery can only guess the well-known path, which not every server layout answers.
const challenge = yield* Effect.tryPromise((signal) =>
fetchFn(input.config.url, {
method: "POST",
headers: {
...input.config.headers,
"content-type": "application/json",
accept: "application/json, text/event-stream",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 0,
method: "initialize",
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: "opencode" } },
}),
signal,
}),
).pipe(
Effect.map((response) => extractWWWAuthenticateParams(response)),
Effect.timeout("5 seconds"),
Effect.orElseSucceed(() => ({ resourceMetadataUrl: undefined, scope: undefined })),
)
const resourceMetadataUrl = challenge.resourceMetadataUrl
// CIMD needs the server to advertise it and accept public clients, and our published document only
// lists the loopback redirect; a configured client_id always wins.
const discovery = yield* Effect.tryPromise({
try: () => discoverOAuthServerInfo(input.config.url, { fetchFn }),
try: () => discoverOAuthServerInfo(input.config.url, { resourceMetadataUrl, fetchFn }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
const cimd =
@@ -290,17 +403,18 @@ export const authorize = (input: {
let authorizationUrl: URL | undefined
const oauthProvider = provider({
redirectUrl: oauth?.redirect_uri ?? `http://127.0.0.1:${port}${redirectPath}`,
scope: oauth?.scope,
state,
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
clientMetadataUrl: cimd ? CLIENT_METADATA_URL : undefined,
discovery,
onRedirect: (url) => {
authorizationUrl = url
return run(Effect.logInfo("mcp oauth awaiting authorization", fields))
},
config: input.config,
store,
clientMetadataUrl: cimd ? CLIENT_METADATA_URL : undefined,
discovery: { ...discovery, resourceMetadataUrl: resourceMetadataUrl?.toString() },
redirect: {
url: oauth?.redirect_uri ?? `http://127.0.0.1:${port}${redirectPath}`,
state,
open: (url) => {
authorizationUrl = url
return run(Effect.logInfo("mcp oauth awaiting authorization", fields))
},
},
})
const finalize = Effect.gen(function* () {
@@ -316,7 +430,7 @@ export const authorize = (input: {
})
yield* Effect.tryPromise({
try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope, fetchFn }),
try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope ?? challenge.scope, fetchFn }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
@@ -333,8 +447,9 @@ export const authorize = (input: {
try: () =>
auth(oauthProvider, {
serverUrl: input.config.url,
authorizationCode: value,
scope: oauth?.scope,
authorizationCode: value.code,
iss: value.iss,
scope: oauth?.scope ?? challenge.scope,
fetchFn,
}),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
+3 -6
View File
@@ -1,8 +1,6 @@
export * as McpStdio from "./stdio.js"
import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js"
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"
import { ReadBuffer, serializeMessage, type JSONRPCMessage, type Transport } from "@modelcontextprotocol/client"
import { Cause, Duration, Effect, Queue, Scope, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner"
@@ -150,7 +148,7 @@ export const make = Effect.fnUntraced(function* (options: Options) {
}),
),
Effect.ignore,
// stdout ending means the server is gone; the SDK transport reports that the same way.
// stdout ending means the server is gone.
Effect.ensuring(
Effect.gen(function* () {
const unexpected = state.phase !== "closed"
@@ -161,8 +159,7 @@ export const make = Effect.fnUntraced(function* (options: Options) {
),
)
// StdioClientTransport pipes stderr into a stream nobody reads. Drain chunks into the debug
// log so chatty servers cannot stall and newline-free output is not buffered without bound.
// Drain stderr into the debug log so chatty servers cannot stall on a full pipe.
yield* Effect.forkScoped(
handle.stderr.pipe(
Stream.decodeText(),
+2 -1
View File
@@ -9,6 +9,7 @@ import { AppProcess } from "@opencode/util/process"
import { Location } from "../../location.js"
import type { Adapter, BranchOptions, DiffOptions } from "../../vcs.js"
import { DiffError } from "../../vcs.js"
import { gitExecutable } from "../../util/git-executable.js"
import {
chunksByFile,
emptyPatch,
@@ -168,7 +169,7 @@ function makeGit(proc: AppProcess.Interface) {
const run = Effect.fnUntraced(
function* (args: string[], opts: { cwd: string; maxOutputBytes?: number }) {
const result = yield* proc.run(
ChildProcess.make("git", [...cfg, ...args], {
ChildProcess.make(gitExecutable, [...cfg, ...args], {
cwd: opts.cwd,
extendEnv: true,
stdin: "ignore",
+5 -5
View File
@@ -94,7 +94,7 @@ type CompactInput = Parameters<Session.Handle["compact"]>[0] & { sessionID: Sess
type ForkInput = {
sessionID: SessionSchema.ID
boundary: SessionSchema.ForkRequestBoundary
before?: SessionMessage.ID
}
export {
@@ -308,17 +308,17 @@ const layer = Layer.effect(
.where(
and(
eq(SessionMessageTable.session_id, input.sessionID),
input.boundary.type === "before" ? eq(SessionMessageTable.id, input.boundary.messageID) : undefined,
input.before ? eq(SessionMessageTable.id, input.before) : undefined,
),
)
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!boundary && input.boundary.type === "before")
if (!boundary && input.before)
return yield* new MessageNotFoundError({
sessionID: input.sessionID,
messageID: input.boundary.messageID,
messageID: input.before,
})
if (!boundary) return yield* new ForkEmptyError({ sessionID: input.sessionID })
const sessionID = SessionSchema.ID.create()
@@ -336,7 +336,7 @@ const layer = Layer.effect(
yield* bus.publish(SessionEvent.Forked, {
sessionID,
parentID: parent.id,
boundary: { ...input.boundary, messageID: boundary.id },
boundary: { type: input.before ? "before" : "through", messageID: boundary.id },
...inherited,
})
return yield* result.get(sessionID).pipe(Effect.orDie)
+1 -7
View File
@@ -40,17 +40,11 @@ export const layer = Layer.effect(
discovered = yield* mcp.tools()
yield* tools.transform((editor) => {
for (const tool of discovered) {
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
editor.add({
name: tool.name,
options: { namespace: namespace(tool.server), codemode: tool.codemode !== false },
description: tool.description ?? "",
input: {
...schema,
type: "object",
properties: schema.properties ?? {},
additionalProperties: false,
},
input: (tool.inputSchema ?? { type: "object", properties: {} }) as JsonSchema.JsonSchema,
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
execute: (input, context) =>
Effect.gen(function* () {
+11
View File
@@ -0,0 +1,11 @@
import path from "path"
import { which } from "./which.js"
export function resolveGitExecutable(platform: NodeJS.Platform, resolved: string | null) {
if (platform !== "win32" || !resolved) return "git"
return path.resolve(resolved)
}
const resolved = process.platform === "win32" ? which("git") : null
export const gitExecutable = resolveGitExecutable(process.platform, resolved)
+4
View File
@@ -1165,6 +1165,7 @@ describe("Config", () => {
disabled: false,
codemode: false,
timeout: { catalog: 10000 },
protocol: "legacy",
},
remote: {
type: "remote",
@@ -1174,6 +1175,7 @@ describe("Config", () => {
disabled: true,
codemode: false,
timeout: { startup: 15000 },
protocol: "2026-07-28",
},
},
},
@@ -1248,6 +1250,7 @@ describe("Config", () => {
disabled: false,
codemode: false,
timeout: { catalog: 10000 },
protocol: "legacy",
},
remote: {
type: "remote",
@@ -1257,6 +1260,7 @@ describe("Config", () => {
disabled: true,
codemode: false,
timeout: { startup: 15000 },
protocol: "2026-07-28",
},
},
})
+2 -1
View File
@@ -11,6 +11,7 @@ import { ConfigAgentPlugin } from "@opencode/core/config/plugin/agent"
import { ConfigCommandPlugin } from "@opencode/core/config/plugin/command"
import { ConfigProviderPlugin } from "@opencode/core/config/plugin/provider"
import { ConfigReferencePlugin } from "@opencode/core/config/plugin/reference"
import { ConfigCompatibilityPlugin } from "@opencode/core/config/plugin/compatibility"
import { ConfigSkillPlugin } from "@opencode/core/config/plugin/skill"
import { Bus } from "@opencode/core/bus"
import { Integration } from "@opencode/core/integration"
@@ -67,7 +68,7 @@ describe("config plugin reloads", () => {
const plugins = yield* Plugin.Service
const skills = yield* Skill.Service
const host = yield* PluginHost.make(plugins)
yield* ConfigSkillPlugin.Plugin.effect(host)
yield* ConfigCompatibilityPlugin.Plugin.effect(host)
expect(yield* skills.list()).toEqual([])
// Finish startup by observing an ordinary config reload before creating the root.
@@ -1,10 +1,9 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
import { Server } from "@modelcontextprotocol/server"
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"
const server = new Server({ name: "output-schema", version: "1.0.0" }, { capabilities: { tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, ({ params }) =>
server.setRequestHandler("tools/list", ({ params }) =>
Promise.resolve(
params?.cursor === "page-2"
? {
+4 -5
View File
@@ -1,10 +1,9 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { GetPromptRequestSchema, ListPromptsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
import { Server } from "@modelcontextprotocol/server"
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"
const server = new Server({ name: "prompts", version: "1.0.0" }, { capabilities: { prompts: {} } })
server.setRequestHandler(ListPromptsRequestSchema, ({ params }) =>
server.setRequestHandler("prompts/list", ({ params }) =>
Promise.resolve(
params?.cursor === "page-2"
? { prompts: [{ name: "second", description: "Second prompt" }] }
@@ -21,7 +20,7 @@ server.setRequestHandler(ListPromptsRequestSchema, ({ params }) =>
),
)
server.setRequestHandler(GetPromptRequestSchema, ({ params }) =>
server.setRequestHandler("prompts/get", ({ params }) =>
Promise.resolve({
messages: [{ role: "user", content: { type: "text", text: params.arguments?.topic ?? "missing" } }],
}),
+9 -18
View File
@@ -1,39 +1,30 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import {
CallToolRequestSchema,
GetPromptRequestSchema,
ListPromptsRequestSchema,
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
ListToolsRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js"
import { Server } from "@modelcontextprotocol/server"
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"
const server = new Server(
{ name: "timeout", version: "1.0.0" },
{ capabilities: { prompts: {}, resources: {}, tools: {} } },
)
server.setRequestHandler(ListToolsRequestSchema, async () => {
server.setRequestHandler("tools/list", async () => {
if (process.env.MCP_TIMEOUT_TARGET === "catalog") await Bun.sleep(100)
return { tools: [{ name: "slow", inputSchema: { type: "object" } }] }
})
server.setRequestHandler(ListPromptsRequestSchema, () => Promise.resolve({ prompts: [{ name: "slow" }] }))
server.setRequestHandler(ListResourcesRequestSchema, async () => {
server.setRequestHandler("prompts/list", () => Promise.resolve({ prompts: [{ name: "slow" }] }))
server.setRequestHandler("resources/list", async () => {
if (process.env.MCP_TIMEOUT_TARGET === "resource-catalog") await Bun.sleep(100)
return { resources: [{ name: "slow", uri: "test://slow" }] }
})
server.setRequestHandler(ListResourceTemplatesRequestSchema, () => Promise.resolve({ resourceTemplates: [] }))
server.setRequestHandler(CallToolRequestSchema, async () => {
server.setRequestHandler("resources/templates/list", () => Promise.resolve({ resourceTemplates: [] }))
server.setRequestHandler("tools/call", async () => {
await Bun.sleep(100)
return { content: [] }
})
server.setRequestHandler(GetPromptRequestSchema, async () => {
server.setRequestHandler("prompts/get", async () => {
await Bun.sleep(100)
return { messages: [] }
})
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
server.setRequestHandler("resources/read", async (request) => {
await Bun.sleep(100)
return { contents: [{ uri: request.params.uri, text: "slow" }] }
})
+7 -5
View File
@@ -17,9 +17,11 @@ const selection = (permissions: Permission.Ruleset = []) => {
}
const instructions = (server: string, text: string) =>
new Mcp.ServerInstructions({ server: Mcp.ServerName.make(server), instructions: text })
({ server: Mcp.ServerName.make(server), instructions: text } satisfies Mcp.ServerInstructions)
const tool = (server: string, name = "search") => new Mcp.Tool({ server: Mcp.ServerName.make(server), name })
const schema = { type: "object" as const }
const tool = (server: string, name = "search") =>
({ server: Mcp.ServerName.make(server), name, inputSchema: schema }) satisfies Mcp.Tool
const layer = (catalog: () => Mcp.ServerInstructions[], tools: () => Mcp.Tool[]) =>
AppNodeBuilder.build(McpInstructions.node, [
@@ -112,19 +114,19 @@ describe("McpInstructions", () => {
Effect.provide(
layer(
() => [instructions("alpha", "Alpha instructions")],
() => [new Mcp.Tool({ server: Mcp.ServerName.make("alpha"), name: "search", codemode: false })],
() => [({ server: Mcp.ServerName.make("alpha"), name: "search", inputSchema: schema, codemode: false }) satisfies Mcp.Tool],
),
),
),
)
it.effect("restates guidance when Code Mode is disabled for a server", () => {
let tools = [tool("alpha")]
let tools: Mcp.Tool[] = [tool("alpha")]
return Effect.gen(function* () {
const service = yield* McpInstructions.Service
const initialized = yield* service.load(selection()).pipe(Effect.flatMap(readInitial))
tools = [new Mcp.Tool({ server: Mcp.ServerName.make("alpha"), name: "search", codemode: false })]
tools = [{ ...tool("alpha"), codemode: false }]
const changed = yield* readUpdate(yield* service.load(selection()), initialized)
expect(changed.text).toBe(
[
+278 -101
View File
@@ -1,29 +1,118 @@
import { afterAll, describe, expect, test } from "bun:test"
import { auth, refreshAuthorization } from "@modelcontextprotocol/sdk/client/auth.js"
import { auth, refreshAuthorization } from "@modelcontextprotocol/client"
import { ConfigMCP } from "@opencode/schema/config/mcp"
import { Credential } from "@opencode/schema/credential"
import { Credential } from "@opencode/core/credential"
import { Integration } from "@opencode/core/integration"
import { McpClient } from "@opencode/core/mcp/client"
import { McpOAuth } from "@opencode/core/mcp/oauth"
import { Effect } from "effect"
import { Cause, Effect, Exit } from "effect"
import { hostEnvironmentLayer } from "./fixture/environment"
const authServer = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 404 }) })
afterAll(() => authServer.stop(true))
const integrationID = Integration.ID.make("mcp_test")
const methodID = Integration.MethodID.make("oauth")
const remote = (url: string) => new ConfigMCP.Remote({ type: "remote", url, oauth: { client_id: "client" } })
const credential = (input: { access: string; refresh: string; expires?: number; url: string }) =>
new Credential.Info({
id: Credential.ID.make("cred_test"),
integrationID,
label: "test",
value: {
type: "oauth",
methodID,
access: input.access,
refresh: input.refresh,
expires: input.expires ?? Date.now() - 1000,
metadata: { serverUrl: input.url, tokenType: "Bearer" },
},
})
// Connect-time providers read and write the credential store; this one lives in memory so tests can
// inspect the row the provider leaves behind.
const memoryCredentials = (initial: Credential.Info[]) => {
const rows = new Map(initial.map((row) => [row.id, row]))
const unused = () => Effect.die("unused credential method")
const service = Credential.Service.of({
all: unused,
create: unused,
activate: unused,
list: (id) => Effect.sync(() => Array.from(rows.values()).filter((row) => row.integrationID === id)),
get: (id) => Effect.sync(() => rows.get(id)),
update: (id, updates) =>
Effect.sync(() => {
const row = rows.get(id)
if (row) rows.set(id, new Credential.Info({ ...row, ...updates }))
}),
remove: (id) => Effect.sync(() => void rows.delete(id)),
})
return { rows, service }
}
const connectProvider = (config: typeof ConfigMCP.Remote.Type, store: ReturnType<typeof memoryCredentials>) =>
Effect.runPromise(
McpOAuth.connectProvider({ config, integrationID }).pipe(Effect.provideService(Credential.Service, store.service)),
)
// Serves authorization server metadata with the given capabilities and records DCR + token requests.
const authorizationServer = (metadata: Record<string, unknown>) => {
const registrations: unknown[] = []
const tokenRequests: URLSearchParams[] = []
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/.well-known/oauth-authorization-server")
return Response.json({
issuer: url.origin,
authorization_endpoint: `${url.origin}/authorize`,
token_endpoint: `${url.origin}/token`,
registration_endpoint: `${url.origin}/register`,
response_types_supported: ["code"],
...metadata,
})
if (request.method === "POST" && url.pathname === "/register") {
registrations.push(await request.json())
return Response.json({ client_id: "registered", redirect_uris: [] })
}
if (request.method === "POST" && url.pathname === "/token") {
tokenRequests.push(new URLSearchParams(await request.text()))
return Response.json({ access_token: "access", token_type: "Bearer" })
}
return new Response(null, { status: 404 })
},
})
return { server, registrations, tokenRequests }
}
const start = (
target: string | ReturnType<typeof Bun.serve>,
oauth?: ConfigMCP.OAuth,
store: ReturnType<typeof memoryCredentials> = memoryCredentials([]),
) =>
Effect.gen(function* () {
const authorization = yield* McpOAuth.authorize({
name: "test",
config: new ConfigMCP.Remote({
type: "remote",
url: typeof target === "string" ? target : target.url.href,
...(oauth ? { oauth } : {}),
}),
integrationID,
methodID,
})
return { authorization, url: new URL(authorization.url) }
}).pipe(Effect.provideService(Credential.Service, store.service))
const authorize = (redirect_uri?: string) =>
Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const authorization = yield* McpOAuth.authorize({
name: "test",
config: new ConfigMCP.Remote({
type: "remote",
url: authServer.url.href,
oauth: { client_id: "client", ...(redirect_uri ? { redirect_uri } : {}) },
}),
methodID: Integration.MethodID.make("oauth"),
})
return new URL(authorization.url).searchParams.get("redirect_uri")
}),
start(authServer, { client_id: "client", ...(redirect_uri ? { redirect_uri } : {}) }).pipe(
Effect.map(({ url }) => url.searchParams.get("redirect_uri")),
),
),
)
@@ -48,16 +137,7 @@ describe("MCP OAuth", () => {
const credential = await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const authorization = yield* McpOAuth.authorize({
name: "test",
config: new ConfigMCP.Remote({
type: "remote",
url: server.url.href,
oauth: { client_id: "client" },
}),
methodID: Integration.MethodID.make("oauth"),
})
const authorizationUrl = new URL(authorization.url)
const { authorization, url: authorizationUrl } = yield* start(server, { client_id: "client" })
const redirectValue = authorizationUrl.searchParams.get("redirect_uri")
const state = authorizationUrl.searchParams.get("state")
if (!redirectValue || !state) throw new Error("Missing OAuth redirect parameters")
@@ -76,6 +156,7 @@ describe("MCP OAuth", () => {
expect(tokenRequests[0]?.get("grant_type")).toBe("authorization_code")
expect(tokenRequests[0]?.get("code")).toBe("accepted")
expect(tokenRequests[0]?.get("code_verifier")).not.toBeNull()
expect(tokenRequests[0]?.get("resource")).toBe(server.url.href)
})
test("refreshes tokens loaded from a persisted credential", async () => {
@@ -89,61 +170,102 @@ describe("MCP OAuth", () => {
return Response.json({ access_token: "next", token_type: "Bearer" })
},
})
const store = McpOAuth.memoryStore()
await store.saveTokens(
McpOAuth.toTokens(
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("oauth"),
access: "expired",
refresh: "refresh",
expires: Date.now() - 1000,
metadata: { serverUrl: server.url.href, tokenType: "Bearer" },
}),
),
)
const oauthProvider = McpOAuth.provider({
redirectUrl: "http://127.0.0.1/callback",
client: { id: "client" },
onRedirect: () => undefined,
store,
})
const store = memoryCredentials([credential({ access: "expired", refresh: "refresh", url: server.url.href })])
const oauthProvider = await connectProvider(remote(server.url.href), store)
const result = await auth(oauthProvider, { serverUrl: server.url.href }).finally(() => server.stop(true))
expect(result).toBe("AUTHORIZED")
expect(await store.tokens()).toEqual({ access_token: "next", token_type: "Bearer", refresh_token: "refresh" })
expect(tokenRequests).toHaveLength(1)
expect(tokenRequests[0]?.get("grant_type")).toBe("refresh_token")
expect(tokenRequests[0]?.get("refresh_token")).toBe("refresh")
// The refreshed tokens land on the same credential row, stamped with the issuer they were minted by.
const stored = store.rows.get(Credential.ID.make("cred_test"))?.value
expect(stored?.type === "oauth" && stored.access).toBe("next")
expect(stored?.type === "oauth" && stored.metadata?.issuer).toBe(server.url.href)
})
test("reports needs_auth for an unauthorized server without registering a client", async () => {
let registrations = 0
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
if (request.method === "POST" && url.pathname === "/register") registrations++
if (url.pathname === "/mcp") return new Response(null, { status: 401 })
return new Response(null, { status: 404 })
},
})
const config = new ConfigMCP.Remote({ type: "remote", url: `${server.url.origin}/mcp` })
const oauthProvider = await connectProvider(config, memoryCredentials([]))
const exit = await Effect.runPromise(
Effect.scoped(McpClient.connect("test", config, import.meta.dir, oauthProvider)).pipe(
Effect.provide(hostEnvironmentLayer),
Effect.exit,
),
).finally(() => server.stop(true))
expect(Exit.isFailure(exit) && Cause.squash(exit.cause)).toBeInstanceOf(McpClient.NeedsAuthError)
expect(registrations).toBe(0)
})
test("drops an invalidated credential only while it still holds the presented token", async () => {
const url = authServer.url.href
const rotated = memoryCredentials([credential({ access: "a", refresh: "r1", url })])
const rotatedProvider = await connectProvider(remote(url), rotated)
await rotatedProvider.tokens()
// Another connection refreshed first; the row now holds a token this provider never presented.
await Effect.runPromise(
rotated.service.update(Credential.ID.make("cred_test"), {
value: credential({ access: "b", refresh: "r2", url }).value,
}),
)
await rotatedProvider.invalidateCredentials?.("tokens")
expect(rotated.rows.size).toBe(1)
const stale = memoryCredentials([credential({ access: "a", refresh: "r1", url })])
const staleProvider = await connectProvider(remote(url), stale)
await staleProvider.tokens()
await staleProvider.invalidateCredentials?.("tokens")
expect(stale.rows.size).toBe(0)
})
test("shares concurrent refreshes for the same token", async () => {
let requests = 0
const pending = Promise.withResolvers<void>()
const options = {
metadata: {
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
},
clientInformation: { client_id: "client" },
refreshToken: "refresh",
fetchFn: async () => {
const server = Bun.serve({
port: 0,
async fetch(request) {
const body = new URLSearchParams(await request.text())
if (body.get("grant_type") !== "refresh_token") return new Response(null, { status: 404 })
requests++
await pending.promise
return Response.json({ access_token: "access", token_type: "Bearer", refresh_token: "next" })
},
})
const fetchFn = await Effect.runPromise(McpOAuth.loggedFetch({ server: "test" }))
const options = {
metadata: {
issuer: server.url.origin,
authorization_endpoint: `${server.url.origin}/authorize`,
token_endpoint: `${server.url.origin}/token`,
response_types_supported: ["code"],
},
clientInformation: { client_id: "client" },
refreshToken: "refresh",
fetchFn,
}
const first = refreshAuthorization(new URL("https://auth.example.com"), options)
const second = refreshAuthorization(new URL("https://auth.example.com"), options)
await Promise.resolve()
const first = refreshAuthorization(new URL(server.url.origin), options)
const second = refreshAuthorization(new URL(server.url.origin), options)
// Both refreshes must reach the token endpoint before the shared response is released.
await new Promise((resolve) => setTimeout(resolve, 50))
expect(requests).toBe(1)
pending.resolve()
expect(await Promise.all([first, second])).toEqual([
const results = await Promise.all([first, second]).finally(() => server.stop(true))
expect(results).toEqual([
{ access_token: "access", token_type: "Bearer", refresh_token: "next" },
{ access_token: "access", token_type: "Bearer", refresh_token: "next" },
])
@@ -165,48 +287,86 @@ describe("MCP OAuth", () => {
await expect(authorize("not a URL")).rejects.toThrow(TypeError)
})
test("sends the configured URL as the resource when the server publishes no metadata", async () => {
const { server, tokenRequests } = authorizationServer({})
const url = `${server.url.origin}/mcp`
const oauthProvider = await connectProvider(
remote(url),
memoryCredentials([credential({ access: "expired", refresh: "refresh", url })]),
)
await auth(oauthProvider, { serverUrl: url }).finally(() => server.stop(true))
expect(tokenRequests[0]?.get("resource")).toBe(url)
})
test("discovers resource metadata without the query the transport dialed", async () => {
const probes: string[] = []
const server = Bun.serve({
port: 0,
fetch(request) {
probes.push(new URL(request.url).pathname + new URL(request.url).search)
return new Response(null, { status: 404 })
},
})
const url = `${server.url.origin}/mcp`
const oauthProvider = await connectProvider(remote(url), memoryCredentials([]))
await auth(oauthProvider, { serverUrl: `${url}?codemode=false` })
.catch(() => undefined)
.finally(() => server.stop(true))
expect(probes).toContain("/.well-known/oauth-protected-resource/mcp")
expect(probes.some((probe) => probe.includes("codemode"))).toBe(false)
})
test("finds resource metadata through the 401 header when the well-known path is not served", async () => {
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/mcp")
return new Response(null, {
status: 401,
headers: { "WWW-Authenticate": `Bearer resource_metadata="${url.origin}/custom/metadata"` },
})
if (url.pathname === "/custom/metadata")
return Response.json({ resource: `${url.origin}/mcp`, authorization_servers: [`${url.origin}/as`] })
if (url.pathname === "/.well-known/oauth-authorization-server/as")
return Response.json({
issuer: `${url.origin}/as`,
authorization_endpoint: `${url.origin}/as/authorize`,
token_endpoint: `${url.origin}/as/token`,
response_types_supported: ["code"],
})
return new Response(null, { status: 404 })
},
})
const { url } = await Effect.runPromise(
Effect.scoped(start(`${server.url.origin}/mcp`, { client_id: "client" })),
).finally(() => server.stop(true))
expect(url.pathname).toBe("/as/authorize")
})
test("forwards iss from the redirect so issuer-advertising servers can complete", async () => {
const { server } = authorizationServer({ authorization_response_iss_parameter_supported: true })
const result = await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const { authorization, url } = yield* start(server)
const redirect = new URL(url.searchParams.get("redirect_uri")!)
redirect.searchParams.set("code", "accepted")
redirect.searchParams.set("state", url.searchParams.get("state")!)
redirect.searchParams.set("iss", server.url.origin)
yield* Effect.promise(() => fetch(redirect))
return yield* authorization.callback
}),
),
).finally(() => server.stop(true))
expect(result.access).toBe("access")
})
describe("client registration", () => {
// Serves authorization server metadata with the given capabilities and records DCR + token requests.
const authorizationServer = (metadata: Record<string, unknown>) => {
const registrations: unknown[] = []
const tokenRequests: URLSearchParams[] = []
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/.well-known/oauth-authorization-server")
return Response.json({
issuer: url.origin,
authorization_endpoint: `${url.origin}/authorize`,
token_endpoint: `${url.origin}/token`,
registration_endpoint: `${url.origin}/register`,
response_types_supported: ["code"],
...metadata,
})
if (request.method === "POST" && url.pathname === "/register") {
registrations.push(await request.json())
return Response.json({ client_id: "registered", redirect_uris: [] })
}
if (request.method === "POST" && url.pathname === "/token") {
tokenRequests.push(new URLSearchParams(await request.text()))
return Response.json({ access_token: "access", token_type: "Bearer" })
}
return new Response(null, { status: 404 })
},
})
return { server, registrations, tokenRequests }
}
const start = (server: ReturnType<typeof Bun.serve>, oauth?: ConfigMCP.OAuth) =>
Effect.gen(function* () {
const authorization = yield* McpOAuth.authorize({
name: "test",
config: new ConfigMCP.Remote({ type: "remote", url: server.url.href, ...(oauth ? { oauth } : {}) }),
methodID: Integration.MethodID.make("oauth"),
})
return { authorization, url: new URL(authorization.url) }
})
const cimd = { client_id_metadata_document_supported: true, token_endpoint_auth_methods_supported: ["none"] }
test("uses the client metadata document when the server supports public CIMD clients", async () => {
@@ -227,7 +387,10 @@ describe("MCP OAuth", () => {
expect(registrations).toHaveLength(0)
expect(tokenRequests[0]?.get("client_id")).toBe(McpOAuth.CLIENT_METADATA_URL)
expect(McpOAuth.clientFromCredential(credential)).toEqual({ client_id: McpOAuth.CLIENT_METADATA_URL })
expect(McpOAuth.clientFromCredential(credential)).toEqual({
client_id: McpOAuth.CLIENT_METADATA_URL,
issuer: server.url.origin,
})
})
test("registers dynamically when the server does not accept public clients", async () => {
@@ -240,6 +403,20 @@ describe("MCP OAuth", () => {
expect(registrations).toHaveLength(1)
})
test("reuses the client registered by an earlier login", async () => {
const { server, registrations } = authorizationServer({})
const previous = credential({ access: "a", refresh: "r", url: server.url.href })
const client = { client_id: "registered", issuer: server.url.origin }
const store = memoryCredentials([
new Credential.Info({ ...previous, value: { ...previous.value, metadata: { client } } }),
])
const { url } = await Effect.runPromise(Effect.scoped(start(server, undefined, store))).finally(() =>
server.stop(true),
)
expect(url.searchParams.get("client_id")).toBe("registered")
expect(registrations).toHaveLength(0)
})
test("registers dynamically when a custom redirect_uri is configured", async () => {
const { server, registrations } = authorizationServer(cimd)
const { url } = await Effect.runPromise(
+295 -138
View File
@@ -1,18 +1,14 @@
import path from "node:path"
import fs from "node:fs/promises"
import { describe, expect, test } from "bun:test"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
import { Client, InMemoryTransport, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"
import {
CallToolRequestSchema,
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
ListToolsRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js"
createMcpHandler,
inputRequired,
inputResponse,
Server,
WebStandardStreamableHTTPServerTransport,
} from "@modelcontextprotocol/server"
import { Document, Event, Info } from "@opencode/schema/config"
import { ConfigMCP } from "@opencode/schema/config/mcp"
import { McpEvent } from "@opencode/schema/mcp-event"
@@ -81,6 +77,8 @@ type ResourceTemplatePage = {
function resourceServer(
input: {
/** Serve 2026-07-28 only through createMcpHandler; the default is a sessionful legacy transport. */
modern?: boolean
resources?: boolean
listChanged?: boolean
emptyElicitation?: boolean
@@ -110,101 +108,137 @@ function resourceServer(
}>,
initializations: 0,
urls: [] as string[],
sessions: [] as string[],
}
const protocol = new Server(
{ name: "mcp-resources", version: "1.0.0" },
{
capabilities: {
tools: {},
...(input.resources === false ? {} : { resources: { listChanged: input.listChanged } }),
// One Server speaks one session, so a restart is a fresh Server and transport. Requests that
// still carry the previous session id are then unknown to the new transport.
const server = () => {
const protocol = new Server(
{ name: "mcp-resources", version: "1.0.0" },
{
capabilities: {
tools: {},
prompts: {},
...(input.resources === false ? {} : { resources: { listChanged: input.listChanged } }),
},
instructions: "Use the resources tools.",
},
},
)
protocol.setRequestHandler(ListToolsRequestSchema, () => {
state.toolLists += 1
return Promise.resolve({
tools: input.emptyElicitation
? [{ name: "empty-elicitation", inputSchema: { type: "object" as const, properties: {} } }]
: input.urlElicitation
? [{ name: "url-elicitation", inputSchema: { type: "object" as const, properties: {} } }]
: [],
})
})
if (input.emptyElicitation) {
protocol.setRequestHandler(CallToolRequestSchema, async () => {
const result = await protocol.elicitInput({
mode: "form",
message: "Confirm",
requestedSchema: { type: "object", properties: {} },
)
protocol.setRequestHandler("tools/list", () => {
state.toolLists += 1
return Promise.resolve({
tools: input.emptyElicitation
? [{ name: "empty-elicitation", inputSchema: { type: "object" as const, properties: {} } }]
: input.urlElicitation
? [{ name: "url-elicitation", inputSchema: { type: "object" as const, properties: {} } }]
: [{ name: "echo", inputSchema: { type: "object" as const, properties: {} } }],
})
return {
content: [{ type: "text", text: JSON.stringify(result) }],
structuredContent: result,
}
})
}
if (input.urlElicitation) {
protocol.setRequestHandler(CallToolRequestSchema, async () => {
const result = await protocol.elicitInput({
mode: "url",
message: "Authorize access",
url: "https://example.com/authorize",
elicitationId: "elicitation-test",
if (input.emptyElicitation) {
protocol.setRequestHandler("tools/call", async () => {
const result = await protocol.elicitInput({
mode: "form",
message: "Confirm",
requestedSchema: { type: "object", properties: {} },
})
return {
content: [{ type: "text", text: JSON.stringify(result) }],
structuredContent: result,
}
})
return {
content: [{ type: "text", text: JSON.stringify(result) }],
structuredContent: result,
}
})
}
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,
}
if (input.urlElicitation) {
const url = "https://example.com/authorize"
// Modern servers cannot call elicitInput; they return input_required and the client retries.
protocol.setRequestHandler("tools/call", async (request, ctx) => {
const responses = ctx.mcpReq.inputResponses
if (input.modern && !responses)
return inputRequired({ inputRequests: { auth: inputRequired.elicitUrl({ message: "Authorize", url }) } })
const response = inputResponse(responses, "auth")
const result = input.modern
? { action: response.kind === "elicit" ? response.action : "cancel" }
: await protocol.elicitInput({
mode: "url",
message: "Authorize access",
url,
elicitationId: "elicitation-test",
})
return {
content: [{ type: "text", text: JSON.stringify(result) }],
structuredContent: result,
}
})
return Promise.resolve({ content: [] })
})
}
if (!input.emptyElicitation && !input.urlElicitation) {
protocol.setRequestHandler("tools/call", (request) => {
state.toolCalls.push({
name: request.params.name,
arguments: request.params.arguments,
sessionID: request.params._meta?.["ai.opencode/sessionID"],
progressToken: request.params._meta?.progressToken,
})
return Promise.resolve({ content: [] })
})
}
protocol.setRequestHandler("prompts/list", () => Promise.resolve({ prompts: [{ name: "greet" }] }))
protocol.setRequestHandler("prompts/get", (request) =>
Promise.resolve({
messages: [{ role: "user", content: { type: "text", text: `hi ${request.params.arguments?.name}` } }],
}),
)
if (input.resources !== false) {
protocol.setRequestHandler("resources/list", (request) => {
state.resourceLists += 1
const page = state.resourcePages?.[request.params?.cursor ?? "initial"]
return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor })
})
protocol.setRequestHandler("resources/templates/list", (request) => {
state.templateLists += 1
const page = state.templatePages?.[request.params?.cursor ?? "initial"]
return Promise.resolve({ resourceTemplates: page?.items ?? state.templates, nextCursor: page?.nextCursor })
})
protocol.setRequestHandler("resources/read", () => Promise.resolve({ contents: state.contents }))
}
return protocol
}
if (input.resources !== false) {
protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
state.resourceLists += 1
const page = state.resourcePages?.[request.params?.cursor ?? "initial"]
return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor })
const build = async () => {
const protocol = server()
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
enableJsonResponse: true,
})
protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => {
state.templateLists += 1
const page = state.templatePages?.[request.params?.cursor ?? "initial"]
return Promise.resolve({ resourceTemplates: page?.items ?? state.templates, nextCursor: page?.nextCursor })
})
protocol.setRequestHandler(ReadResourceRequestSchema, () => Promise.resolve({ contents: state.contents }))
await protocol.connect(transport)
return { protocol, transport }
}
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
enableJsonResponse: true,
})
await protocol.connect(transport)
let current = await build()
const modern = input.modern ? createMcpHandler(server, { legacy: "reject" }) : undefined
const http = Bun.serve({
port: 0,
fetch: async (request) => {
state.urls.push(request.url)
const session = request.headers.get("mcp-session-id")
if (session !== null && !state.sessions.includes(session)) state.sessions.push(session)
const body: unknown = request.method === "POST" ? await request.clone().json() : undefined
if (typeof body === "object" && body !== null && "method" in body && body.method === "initialize") {
state.initializations += 1
}
return (await input.respond?.(request)) ?? transport.handleRequest(request)
return (await input.respond?.(request)) ?? modern?.fetch(request) ?? current.transport.handleRequest(request)
},
})
return {
state,
url: http.url.toString(),
clientVersion: () => protocol.getClientVersion(),
sendResourceListChanged: () => protocol.sendResourceListChanged(),
completeElicitation: () => protocol.createElicitationCompletionNotifier("elicitation-test")(),
clientVersion: () => current.protocol.getClientVersion(),
sendResourceListChanged: () =>
modern ? Promise.resolve(modern.notify.resourcesChanged()) : current.protocol.sendResourceListChanged(),
completeElicitation: () => current.protocol.createElicitationCompletionNotifier("elicitation-test")(),
restart: async () => {
await current.protocol.close().catch(() => {})
current = await build()
},
close: async () => {
await protocol.close().catch(() => {})
await current.protocol.close().catch(() => {})
await modern?.close()
await http.stop(true)
},
}
@@ -304,10 +338,18 @@ function resourceMcpLayer(
const connect = (server: string, config: typeof ConfigMCP.Server.Type, directory: string) =>
McpClient.connect(server, config, directory).pipe(Effect.provide(hostEnvironmentLayer))
// Reads no longer wait for startup, so tests that assert on a connected server settle it first.
const settled = (service: Mcp.Interface, name = "resources") =>
Effect.gen(function* () {
const status = (yield* service.servers()).find((server) => server.name === name)?.status
if (status?.status === "pending") return yield* Effect.fail(status)
return status
}).pipe(Effect.retry({ times: 200, schedule: Schedule.spaced("10 millis") }))
const mcp = Layer.mock(Mcp.Service, {
tools: () =>
Effect.succeed([
new Mcp.Tool({
{
server: Mcp.ServerName.make("demo"),
name: "search",
description: "Search",
@@ -317,74 +359,74 @@ const mcp = Layer.mock(Mcp.Service, {
properties: { ok: { type: "boolean" } },
required: ["ok"],
},
}),
new Mcp.Tool({
} satisfies Mcp.Tool,
{
server: Mcp.ServerName.make("demo"),
name: "status",
description: "Status",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
} satisfies Mcp.Tool,
{
server: Mcp.ServerName.make("demo"),
name: "issues",
description: "Returns JSON as text",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
} satisfies Mcp.Tool,
{
server: Mcp.ServerName.make("demo"),
name: "count",
description: "Returns a number as text",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
} satisfies Mcp.Tool,
{
server: Mcp.ServerName.make("demo"),
name: "typed",
description: "Declares a string output and returns JSON as text",
inputSchema: { type: "object", properties: {} },
outputSchema: { type: "string" },
}),
new Mcp.Tool({
} satisfies Mcp.Tool,
{
server: Mcp.ServerName.make("direct"),
name: "issues",
codemode: false,
description: "Returns JSON as text",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
} satisfies Mcp.Tool,
{
server: Mcp.ServerName.make("direct"),
name: "lookup",
codemode: false,
description: "Lookup",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
} satisfies Mcp.Tool,
{
server: Mcp.ServerName.make("direct"),
name: "fail",
codemode: false,
description: "Always fails",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
} satisfies Mcp.Tool,
{
server: Mcp.ServerName.make("direct"),
name: "media",
codemode: false,
description: "Returns text and an image",
inputSchema: { type: "object", properties: {} },
}),
} satisfies Mcp.Tool,
]),
callTool: (input) =>
Effect.sync(() => {
calls += 1
invocations.push(input)
if (input.name === "fail")
return new Mcp.ToolResult({
return {
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: true,
content: [{ type: "text", text: "search index unavailable" }],
})
} satisfies Mcp.ToolResult
if (input.name === "media")
return new Mcp.ToolResult({
return {
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
@@ -392,35 +434,35 @@ const mcp = Layer.mock(Mcp.Service, {
{ type: "text", text: "rendered chart" },
{ type: "media", data: "aGVsbG8=", mimeType: "image/png" },
],
})
} satisfies Mcp.ToolResult
if (input.name === "status")
return new Mcp.ToolResult({
return {
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: "hello" }],
})
} satisfies Mcp.ToolResult
if (input.name === "issues" || input.name === "typed")
return new Mcp.ToolResult({
return {
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: '{"issues":[{"id":1}]}' }],
})
} satisfies Mcp.ToolResult
if (input.name === "count")
return new Mcp.ToolResult({
return {
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: "42" }],
})
return new Mcp.ToolResult({
} satisfies Mcp.ToolResult
return {
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
structured: { ok: true },
content: [],
})
} satisfies Mcp.ToolResult
}),
})
const permissions = Layer.mock(Permission.Service, {
@@ -496,7 +538,7 @@ test("passes session IDs as MCP request metadata", async () => {
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 }) =>
server.setRequestHandler("tools/list", ({ params }) =>
Promise.resolve(
params?.cursor === "page-2"
? {
@@ -528,7 +570,7 @@ test("preserves output schema validation across paginated tool discovery", async
},
),
)
server.setRequestHandler(CallToolRequestSchema, ({ params }) =>
server.setRequestHandler("tools/call", ({ params }) =>
Promise.resolve({
content: [],
structuredContent: { value: params.name === "first" ? 42 : 1 },
@@ -540,12 +582,16 @@ test("preserves output schema validation across paginated tool discovery", async
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)])
try {
const first = await client.listTools()
const second = await client.listTools({ cursor: first.nextCursor })
expect([...first.tools, ...second.tools].map((tool) => tool.name)).toEqual(["first", "second"])
// Without a cursor the SDK walks every page; the page-1 validator must survive the page-2 fetch.
const listed = await client.listTools()
expect(listed.tools.map((tool) => tool.name)).toEqual(["first", "second"])
expect(listed.nextCursor).toBeUndefined()
await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow(
"Structured content does not match the tool's output schema",
)
await expect(client.callTool({ name: "second", arguments: {} })).resolves.toMatchObject({
structuredContent: { value: 1 },
})
} finally {
await Promise.all([client.close(), server.close()])
}
@@ -658,8 +704,7 @@ test("reports a local MCP server as failed when the location has no execution pl
await Effect.runPromise(
Effect.gen(function* () {
const service = yield* Mcp.Service
yield* service.tools()
const status = (yield* service.servers()).find((server) => server.name === "resources")?.status
const status = yield* settled(service)
expect(status).toEqual({
status: "failed",
error: expect.stringContaining("location has no execution plane"),
@@ -1026,16 +1071,91 @@ for (const status of [400, 404]) {
const config = new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false })
const connection = yield* connect("resources", config, import.meta.dir)
expired = true
expect(yield* connection.tools().pipe(Effect.flip)).toBeInstanceOf(Error)
const error = yield* connection.tools().pipe(Effect.flip)
// The SDK tries to recover an expired session on 404, but must keep the same URL.
expect(server.state.initializations).toBe(status === 404 ? 2 : 1)
// A 404 against a live session is reported as an expiry for the lifecycle to recover; the
// connection itself never re-initializes or changes URL.
if (status === 404) expect(error).toBeInstanceOf(McpClient.SessionExpiredError)
else expect(error).not.toBeInstanceOf(McpClient.SessionExpiredError)
expect(server.state.initializations).toBe(1)
expect(new Set(server.state.urls)).toEqual(new Set([server.url + "?codemode=false"]))
expect(server.state.toolLists).toBe(0)
}),
)
}
test("reconnects and retries a tool call after the MCP session expires", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer()
yield* Effect.gen(function* () {
const service = yield* Mcp.Service
yield* service.callTool({ server: "resources", name: "echo", args: { n: 1 } })
expect(server.state.toolCalls).toHaveLength(1)
expect(server.state.initializations).toBe(1)
// The restarted server does not know the client's session, so the next request 404s.
yield* Effect.promise(server.restart)
const result = yield* service.callTool({ server: "resources", name: "echo", args: { n: 2 } })
expect(result.isError).toBe(false)
expect(server.state.toolCalls.map((call) => call.arguments)).toEqual([{ n: 1 }, { n: 2 }])
expect(server.state.initializations).toBe(2)
expect(server.state.sessions).toHaveLength(2)
expect((yield* service.servers()).find((entry) => entry.name === "resources")?.status).toEqual({
status: "connected",
})
}).pipe(Effect.provide(resourceMcpLayer(server.url)))
}),
),
)
})
describe.each([
["legacy", undefined],
["modern", "2026-07-28"],
] as const)("MCP connection over the %s protocol", (era, protocol) => {
test("exposes every connection operation", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer({ modern: era === "modern", listChanged: true })
server.state.resources = [{ name: "Readme", uri: "docs://readme" }]
server.state.templates = [{ name: "File", uriTemplate: "docs://{path}" }]
const connection = yield* connect(
"resources",
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, protocol }),
import.meta.dir,
)
expect(connection.modern).toBe(era === "modern")
expect(connection.instructions).toBe("Use the resources tools.")
expect((yield* connection.tools()).map((tool) => tool.name)).toEqual(["echo"])
expect((yield* connection.prompts()).map((prompt) => prompt.name)).toEqual(["greet"])
expect((yield* connection.resources()).map((resource) => resource.uri)).toEqual(["docs://readme"])
expect((yield* connection.resourceTemplates()).map((template) => template.uriTemplate)).toEqual([
"docs://{path}",
])
expect((yield* connection.readResource({ uri: "docs://readme" }))?.contents).toHaveLength(2)
expect((yield* connection.prompt({ name: "greet", args: { name: "bob" } })).messages[0]?.content).toEqual({
type: "text",
text: "hi bob",
})
const sessionID = Session.ID.make("ses_mcp_era")
yield* connection.callTool({ name: "echo", args: { text: "hi" }, sessionID })
expect(server.state.toolCalls.at(-1)).toMatchObject({ name: "echo", sessionID })
const changed = yield* Deferred.make<void>()
connection.onResourcesChanged(() => Deferred.doneUnsafe(changed, Exit.void))
yield* Effect.promise(server.sendResourceListChanged)
yield* Deferred.await(changed)
}),
),
)
})
})
test("lists, reads, and reports MCP resource changes", async () => {
await Effect.runPromise(
Effect.scoped(
@@ -1071,8 +1191,8 @@ test("lists, reads, and reports MCP resource changes", async () => {
])
expect(yield* connection.readResource({ uri: "docs://readme" })).toEqual({
contents: [
{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
{ type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
{ uri: "docs://readme", text: "hello", mimeType: "text/plain" },
{ uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
],
})
@@ -1190,6 +1310,36 @@ test("acknowledges completed MCP URL elicitations without returning internal con
)
})
test("settles modern MCP URL elicitations when the user confirms", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer({ modern: true, resources: false, urlElicitation: true })
const created = yield* Deferred.make<Form.Info>()
const result = yield* Effect.gen(function* () {
const service = yield* Mcp.Service
const forms = yield* Form.Service
const call = yield* service.callTool({ server: "resources", name: "url-elicitation" }).pipe(Effect.forkScoped)
const form = yield* Deferred.await(created)
expect(form.metadata).not.toHaveProperty("elicitationID")
yield* forms.reply({ id: form.id, answer: { elicitation: true } })
return yield* Fiber.join(call)
}).pipe(
Effect.provide(
resourceMcpLayer(
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, protocol: "2026-07-28" }),
(form) => Deferred.succeed(created, form).pipe(Effect.asVoid),
),
),
)
expect(result.structured).toEqual({ action: "accept" })
}),
),
)
})
test("loads and reads MCP resources", async () => {
await Effect.runPromise(
Effect.scoped(
@@ -1200,6 +1350,7 @@ test("loads and reads MCP resources", async () => {
yield* Effect.gen(function* () {
const service = yield* Mcp.Service
yield* settled(service)
expect(yield* service.resourceCatalog()).toEqual({
resources: [
{
@@ -1326,6 +1477,7 @@ testEffect(Layer.empty).live(
timeout: { startup: 10, catalog: 20, execution: 30 },
servers: {
resources: { type: "local", command: ["earlier"], disabled: true, timeout: { execution: 90 } },
pinned: { type: "local", command: ["pinned"], disabled: true, protocol: "2026-07-28" },
},
}),
}),
@@ -1352,6 +1504,13 @@ testEffect(Layer.empty).live(
disabled: true,
timeout: { startup: 50, catalog: 40, execution: 30 },
})
expect(editor.get("pinned")).toEqual({
type: "local",
command: ["pinned"],
disabled: true,
timeout: { startup: 10, catalog: 40, execution: 30 },
protocol: "2026-07-28",
})
})
yield* check.dispose
const runtime = {
@@ -1557,7 +1716,7 @@ test("reconciles only changed MCP server config", async () => {
yield* Effect.gen(function* () {
const service = yield* Mcp.Service
yield* service.tools()
yield* settled(service)
expect(server.state.toolLists).toBe(1)
expect(server.state.initializations).toBe(1)
@@ -1785,13 +1944,13 @@ test("serializes concurrent MCP lifecycle operations", async () => {
testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin transforms through catalog updates", () =>
Effect.gen(function* () {
const tool = (server: string, name: string, description = name) =>
new Mcp.Tool({
({
server: Mcp.ServerName.make(server),
name,
description,
codemode: false,
inputSchema: { type: "object", properties: {} },
})
}) satisfies Mcp.Tool
const healthy = [tool("demo", "search"), tool("other", "lookup")]
const namespace = tool("x".repeat(65), "lookup")
const catalog = yield* Ref.make([tool("demo", "x".repeat(65)), ...healthy, namespace])
@@ -1913,14 +2072,12 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin tr
Layer.mock(Mcp.Service, {
tools: () => Ref.get(catalog),
callTool: (input) =>
Effect.succeed(
new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: "healthy" }],
}),
),
Effect.succeed({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: "healthy" }],
} satisfies Mcp.ToolResult),
}),
),
Permission.node.replace(Layer.mock(Permission.Service, { assert: () => Effect.void })),
@@ -1957,12 +2114,12 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in
Layer.mock(Mcp.Service, {
tools: () =>
Effect.sync(() => [
new Mcp.Tool({
{
server: Mcp.ServerName.make("demo"),
name: `read_${++reads}`,
codemode: false,
inputSchema: { type: "object", properties: {} },
}),
} satisfies Mcp.Tool,
]),
}),
),
@@ -75,11 +75,11 @@ function required<T>(value: T | undefined): T {
return value
}
function eventually<A>(
effect: Effect.Effect<A>,
function eventually<A, E>(
effect: Effect.Effect<A, E>,
predicate: (value: A) => boolean,
remaining = 1000,
): Effect.Effect<A, Error> {
): Effect.Effect<A, E | Error> {
return Effect.gen(function* () {
const value = yield* effect
if (predicate(value)) return value
+13 -13
View File
@@ -380,7 +380,7 @@ describe("Session.create", () => {
yield* session.prompt({ sessionID: created.id, text: "Fork context", resume: false })
yield* SessionInbox.promote(db, bus, created.id, "steer")
const forked = yield* session.fork({ sessionID: created.id, boundary: { type: "through" } })
const forked = yield* session.fork({ sessionID: created.id })
expect(forked.metadata).toEqual(metadata)
// Absent stays absent: no empty-object normalization.
@@ -402,7 +402,7 @@ describe("Session.create", () => {
yield* session.prompt({ sessionID: created.id, text: "Fork context", resume: false })
yield* SessionInbox.promote(db, bus, created.id, "steer")
const forked = yield* session.fork({ sessionID: created.id, boundary: { type: "through" } })
const forked = yield* session.fork({ sessionID: created.id })
expect(forked.permissions).toEqual(permissions)
const replaced = [{ action: "shell", resource: "*", effect: "ask" as const }]
@@ -541,7 +541,7 @@ describe("Session.create", () => {
yield* session.synthetic({ sessionID: parent.id, text: "parent note", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const forked = yield* session.fork({ sessionID: parent.id })
const parentContext = yield* session.context(parent.id)
const forkContext = yield* session.context(forked.id)
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
@@ -596,7 +596,7 @@ describe("Session.create", () => {
yield* session.prompt({ sessionID: parent.id, text: "First", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const forked = yield* session.fork({ sessionID: parent.id })
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, forked.id)).get().pipe(Effect.orDie)
expect(forked.title).toBeUndefined()
@@ -614,7 +614,7 @@ describe("Session.create", () => {
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* session.synthetic({ sessionID: parent.id, text: "Second", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const forked = yield* session.fork({ sessionID: parent.id })
const original = (yield* session.context(forked.id)).map((message) => message.id)
const recorded = yield* db
.select()
@@ -657,7 +657,7 @@ describe("Session.create", () => {
{ discard: true },
)
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const forked = yield* session.fork({ sessionID: parent.id })
const inheritedList = yield* entries.list(forked.id)
const inheritedValues = yield* entries.load(forked.id).pipe(Effect.flatMap(Instructions.read))
@@ -730,7 +730,7 @@ describe("Session.create", () => {
executed: true,
})
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const forked = yield* session.fork({ sessionID: parent.id })
expect(yield* session.context(parent.id)).toMatchObject([
Expected.user("Run both tools"),
@@ -760,7 +760,7 @@ describe("Session.create", () => {
})
yield* bus.publish(SessionEvent.Shell.Started, { sessionID: parent.id, shell })
const running = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const running = yield* session.fork({ sessionID: parent.id })
expect(yield* session.context(parent.id)).toMatchObject([
Expected.user("Run a shell"),
@@ -773,7 +773,7 @@ describe("Session.create", () => {
shell: { ...shell, status: "exited", exit: 0, time: { started: 0, completed: 1 } },
output: { output: "complete", cursor: 8, size: 8, truncated: false },
})
const completed = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const completed = yield* session.fork({ sessionID: parent.id })
expect(yield* session.context(running.id)).toMatchObject([Expected.user("Run a shell")])
expect(yield* session.context(completed.id)).toMatchObject([
@@ -789,7 +789,7 @@ describe("Session.create", () => {
const parent = yield* session.create({ location })
expect(
yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } }).pipe(Effect.flip),
yield* session.fork({ sessionID: parent.id }).pipe(Effect.flip),
).toMatchObject({ _tag: "Session.ForkEmptyError", sessionID: parent.id })
}),
)
@@ -830,13 +830,13 @@ describe("Session.create", () => {
const forked = yield* session.fork({
sessionID: parent.id,
boundary: { type: "before", messageID: second.id },
before: second.id,
})
const beforeFirst = yield* session.fork({
sessionID: parent.id,
boundary: { type: "before", messageID: first.id },
before: first.id,
})
const complete = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const complete = yield* session.fork({ sessionID: parent.id })
const context = yield* session.context(forked.id)
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
+1 -1
View File
@@ -186,7 +186,7 @@ describe("Session.diff", () => {
expect(yield* diff({ from: steer }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageNotFoundError",
})
const forked = yield* sessions.fork({ sessionID: created.id, boundary: { type: "through" } })
const forked = yield* sessions.fork({ sessionID: created.id })
expect((yield* sessions.diff({ sessionID: forked.id, context: 0 })).map(summarize)).toEqual([
["third.txt", "added", 1, 0],
])
+5 -5
View File
@@ -1497,7 +1497,7 @@ describe("SessionRunnerLLM", () => {
expect(continued.system.map((part) => part.text)).toContain("Checkpoint instructions")
expect(systemTexts(continued)).toEqual(["Newest instructions"])
const forked = yield* s.session.fork({ sessionID, boundary: { type: "before", messageID: after.id } })
const forked = yield* s.session.fork({ sessionID, before: after.id })
yield* s.session.prompt({ sessionID: forked.id, text: "Fork prompt", resume: false })
yield* s.session.resume(forked.id)
expect(s.requests.at(-1)?.messages[0]).toEqual(replacement[0])
@@ -1552,7 +1552,7 @@ describe("SessionRunnerLLM", () => {
s.systemBaseline = "Latest context"
yield* s.runPrompt("Third")
const forked = yield* s.session.fork({ sessionID, boundary: { type: "before", messageID: second.id } })
const forked = yield* s.session.fork({ sessionID, before: second.id })
expect(
yield* s.db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, forked.id)).get(),
).toMatchObject({
@@ -1597,14 +1597,14 @@ describe("SessionRunnerLLM", () => {
s.systemBaseline = "Changed context"
const second = yield* s.runPrompt("Second")
const child = yield* s.session.fork({ sessionID, boundary: { type: "before", messageID: second.id } })
const child = yield* s.session.fork({ sessionID, before: second.id })
const inheritedFirst = (yield* s.session.messages({ sessionID: child.id })).find(
(message) => message.type === "user" && message.text === "First",
)
if (!inheritedFirst) return yield* Effect.die(new Error("Nested fork boundary message not found"))
const grandchild = yield* s.session.fork({
sessionID: child.id,
boundary: { type: "before", messageID: inheritedFirst.id },
before: inheritedFirst.id,
})
expect(
@@ -2036,7 +2036,7 @@ describe("SessionRunnerLLM", () => {
yield* replaySessionProjection(sessionID)
const latest = yield* s.runPrompt("Third")
expect(systemTexts(s.requests[3])).toEqual(["Replacement context"])
const fork = yield* s.session.fork({ sessionID, boundary: { type: "before", messageID: latest.id } })
const fork = yield* s.session.fork({ sessionID, before: latest.id })
expect(
(yield* s.session.context(fork.id)).flatMap((message) => (message.type === "system" ? [message.text] : [])),
).toEqual(["Replacement context"])
+1 -1
View File
@@ -129,7 +129,7 @@ describe("Session.skill", () => {
resume: false,
})
yield* SessionInbox.promote(database.db, bus, session.id, "steer")
const forked = yield* sessions.fork({ sessionID: session.id, boundary: { type: "before", messageID: selected } })
const forked = yield* sessions.fork({ sessionID: session.id, before: selected })
expect(yield* sessions.messages({ sessionID: forked.id })).toEqual([
expect.objectContaining({ type: "user", text: "Before the skill" }),
-25
View File
@@ -127,31 +127,6 @@ describe("Snapshot", () => {
),
)
testEffect(Layer.empty).live("treats fatal ignore checks as unavailable captures", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
yield* Effect.promise(async () => {
await fs.mkdir(project)
await Bun.write(path.join(project, "tracked.txt"), "one\n")
await initGit(project)
})
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
expect(yield* snapshot.capture()).toBeDefined()
yield* Effect.promise(async () => {
await Bun.write(path.join(project, "tracked.txt"), "two\n")
await Bun.write(path.join(project, ".git", "config"), "[broken\n")
})
expect(yield* snapshot.capture()).toBeUndefined()
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
testEffect(Layer.empty).live("applies availability transforms", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),

Some files were not shown because too many files have changed in this diff Show More