mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-14 12:56:23 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5120f6c1c4 | ||
|
|
cbe8823671 | ||
|
|
6e43875123 | ||
|
|
ce380fba29 | ||
|
|
3045156fa6 | ||
|
|
0a3dc16961 | ||
|
|
1d4d9b5fa5 | ||
|
|
752d980770 | ||
|
|
07225172df | ||
|
|
223b0271e6 | ||
|
|
93a37958a7 | ||
|
|
40f4778296 | ||
|
|
e9bb6d490b | ||
|
|
1e697962bd | ||
|
|
4418df5d62 | ||
|
|
052be04466 | ||
|
|
cd3a64b225 | ||
|
|
48c9a0a8de | ||
|
|
48875190ef | ||
|
|
476432de1e | ||
|
|
a71bb4d38c | ||
|
|
5d3019e5a1 |
@@ -9,6 +9,7 @@ on:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: typecheck
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -32,6 +32,7 @@ target
|
||||
# Local dev files
|
||||
opencode-dev
|
||||
UPCOMING_CHANGELOG.md
|
||||
RELEASE_REVIEW.md
|
||||
logs/
|
||||
*.bun-build
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
+18
-18
@@ -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` | | |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {},
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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" } },
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>`.
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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> =>
|
||||
|
||||
@@ -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)}.`)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`),
|
||||
)
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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.')
|
||||
})
|
||||
})
|
||||
@@ -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.",
|
||||
|
||||
@@ -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" },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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))),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
? {
|
||||
|
||||
@@ -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" } }],
|
||||
}),
|
||||
|
||||
@@ -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" }] }
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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],
|
||||
])
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user