Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1030db291 | |||
| a4fed69a82 | |||
| 41a3cfcdd9 | |||
| 72845e09fc | |||
| abaab29cb3 | |||
| 30936a9bca | |||
| a9144eccf8 | |||
| 458ec7b372 | |||
| d46c02ba73 | |||
| caedf36844 | |||
| 4c6e2a92ce | |||
| bf58fae51f | |||
| eb3476660f | |||
| 7d48a22a63 | |||
| 3f0fc22b6e | |||
| 2409c7a3d5 |
+3
-1
@@ -17,4 +17,6 @@ if (process.versions.bun !== expectedBunVersion) {
|
||||
console.warn(`Warning: Bun version ${process.versions.bun} differs from expected ${expectedBunVersion}`);
|
||||
}
|
||||
'
|
||||
bun typecheck
|
||||
# Only typecheck packages affected since the merge base with origin/dev,
|
||||
# plus their dependents. Override the base with TURBO_SCM_BASE if needed.
|
||||
TURBO_SCM_BASE="${TURBO_SCM_BASE:-origin/dev}" bun turbo typecheck --affected
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"glob": "13.0.5",
|
||||
"husky": "9.1.7",
|
||||
"node-gyp": "12.4.0",
|
||||
"oxlint": "1.60.0",
|
||||
"oxlint-tsgolint": "0.21.0",
|
||||
"prettier": "3.6.2",
|
||||
@@ -3600,7 +3599,7 @@
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
|
||||
"err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
|
||||
|
||||
@@ -4522,7 +4521,7 @@
|
||||
|
||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||
|
||||
"node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="],
|
||||
"node-gyp": ["node-gyp@12.3.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="],
|
||||
|
||||
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
|
||||
|
||||
@@ -5372,7 +5371,7 @@
|
||||
|
||||
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
|
||||
|
||||
"undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
|
||||
"undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
@@ -5574,7 +5573,7 @@
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
||||
@@ -5622,6 +5621,8 @@
|
||||
|
||||
"@actions/github/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||
|
||||
"@actions/http-client/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
|
||||
|
||||
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="],
|
||||
@@ -5874,8 +5875,6 @@
|
||||
|
||||
"@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"@effect/platform-node/undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="],
|
||||
|
||||
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
||||
|
||||
"@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
@@ -5886,16 +5885,12 @@
|
||||
|
||||
"@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
|
||||
|
||||
"@electron/get/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
|
||||
"@electron/get/undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="],
|
||||
|
||||
"@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
|
||||
|
||||
"@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="],
|
||||
|
||||
"@electron/rebuild/node-gyp": ["node-gyp@12.3.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="],
|
||||
|
||||
"@electron/universal/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
|
||||
|
||||
"@electron/universal/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||
@@ -5946,8 +5941,6 @@
|
||||
|
||||
"@npmcli/query/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
|
||||
|
||||
"@npmcli/run-script/node-gyp": ["node-gyp@12.3.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.10", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
|
||||
@@ -6222,8 +6215,6 @@
|
||||
|
||||
"conf/dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="],
|
||||
|
||||
"conf/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
|
||||
"config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
|
||||
|
||||
"crc/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
|
||||
@@ -6362,7 +6353,9 @@
|
||||
|
||||
"nitro/undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="],
|
||||
|
||||
"node-gyp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"node-gyp/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
|
||||
|
||||
"node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
@@ -6466,6 +6459,8 @@
|
||||
|
||||
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||
|
||||
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"terser/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
@@ -6952,6 +6947,8 @@
|
||||
|
||||
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
|
||||
|
||||
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
@@ -7082,10 +7079,6 @@
|
||||
|
||||
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"motion/framer-motion/motion-dom": ["motion-dom@12.40.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="],
|
||||
|
||||
"motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
|
||||
@@ -7098,8 +7091,6 @@
|
||||
|
||||
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="],
|
||||
|
||||
"openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
@@ -7368,8 +7359,6 @@
|
||||
|
||||
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
"app-builder-lib/hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||
|
||||
"archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
|
||||
@@ -7392,6 +7381,8 @@
|
||||
|
||||
"editorconfig/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
@@ -7500,8 +7491,6 @@
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"electron-builder/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"electron-builder/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-ZmAIcG3wPgssKejLe/EMJOjPuMHCyRiiItRvSqIjaME=",
|
||||
"aarch64-linux": "sha256-lbAHoVI/CvgdrfKZ/9dKZ/O7DxJvuWEOrDgcCuFGPBk=",
|
||||
"aarch64-darwin": "sha256-Nrs4ijvzQ3J8AYigJSlsr66feUvLZ5iiYCfBdaZNJEc=",
|
||||
"x86_64-darwin": "sha256-ltCaJSO7kZWuMOU49269xvboNVX35OJDFj0gXCDf62Y="
|
||||
"x86_64-linux": "sha256-ovrz0pALxRek5VEqSYkVpzb9YeiiVQWxfAHZQ3kX0/0=",
|
||||
"aarch64-linux": "sha256-+sqBt11Nl1fDrL1JvFAcN8JMWahspfbBPuMeM7YdagU=",
|
||||
"aarch64-darwin": "sha256-PsZfcmMpz/Xzudjv4CQZu+wPfnyijrlVYeP2x9tRffk=",
|
||||
"x86_64-darwin": "sha256-4TpIc3Gh9nDaA5Y1zkCq0KraMdWu+PWtYVY3p7/CsJ0="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,6 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"glob": "13.0.5",
|
||||
"husky": "9.1.7",
|
||||
"node-gyp": "12.4.0",
|
||||
"oxlint": "1.60.0",
|
||||
"oxlint-tsgolint": "0.21.0",
|
||||
"prettier": "3.6.2",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { matchesModelSearch } from "./dialog-select-model-search"
|
||||
|
||||
describe("matchesModelSearch", () => {
|
||||
test("matches model names across separators", () => {
|
||||
expect(matchesModelSearch("gpt 5", ["GPT-5.5"])).toBe(true)
|
||||
expect(matchesModelSearch("gpt-5", ["GPT-5.5"])).toBe(true)
|
||||
expect(matchesModelSearch("gpt5", ["GPT-5.5"])).toBe(true)
|
||||
})
|
||||
|
||||
test("matches any searchable model field", () => {
|
||||
expect(matchesModelSearch("open ai", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(true)
|
||||
expect(matchesModelSearch("gpt 5", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(true)
|
||||
})
|
||||
|
||||
test("does not match unrelated searches", () => {
|
||||
expect(matchesModelSearch("claude", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
export const normalizeModelSearch = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{Letter}\p{Number}]+/gu, " ")
|
||||
.trim()
|
||||
.replace(/\s+/g, " ")
|
||||
|
||||
export const compactModelSearch = (value: string) => normalizeModelSearch(value).replaceAll(" ", "")
|
||||
|
||||
export const matchesModelSearch = (query: string, values: string[]) => {
|
||||
const search = normalizeModelSearch(query)
|
||||
if (!search) return true
|
||||
|
||||
const compactSearch = compactModelSearch(query)
|
||||
return values.some(
|
||||
(value) => normalizeModelSearch(value).includes(search) || compactModelSearch(value).includes(compactSearch),
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||
import { Component, ComponentProps, createMemo, For, JSX, Show, ValidComponent } from "solid-js"
|
||||
import {
|
||||
Component,
|
||||
ComponentProps,
|
||||
createEffect,
|
||||
createMemo,
|
||||
For,
|
||||
JSX,
|
||||
onCleanup,
|
||||
Show,
|
||||
ValidComponent,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -17,6 +27,9 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { matchesModelSearch } from "./dialog-select-model-search"
|
||||
|
||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
provider === "opencode" && (!cost || cost.input === 0)
|
||||
@@ -242,14 +255,9 @@ export function ModelSelectorPopoverV2(props: {
|
||||
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
|
||||
)
|
||||
const models = createMemo(() => {
|
||||
const search = store.search.trim().toLowerCase()
|
||||
const search = store.search.trim()
|
||||
const filtered = search
|
||||
? allModels().filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(search) ||
|
||||
item.id.toLowerCase().includes(search) ||
|
||||
item.provider.name.toLowerCase().includes(search),
|
||||
)
|
||||
? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
||||
: allModels()
|
||||
|
||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
||||
@@ -333,19 +341,23 @@ export function ModelSelectorPopoverV2(props: {
|
||||
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
||||
}
|
||||
const setSearch = (value: string) => {
|
||||
const search = value.trim().toLowerCase()
|
||||
const search = value.trim()
|
||||
const first = [...allModels()]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.find(
|
||||
(item) =>
|
||||
!search ||
|
||||
item.name.toLowerCase().includes(search) ||
|
||||
item.id.toLowerCase().includes(search) ||
|
||||
item.provider.name.toLowerCase().includes(search),
|
||||
)
|
||||
.find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
||||
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!store.open) return
|
||||
createEventListener(
|
||||
document,
|
||||
"keydown",
|
||||
(event: KeyboardEvent) => handleDocumentSearchKeydown(searchRef, event, store.search, setSearch),
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
||||
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||
|
||||
@@ -218,6 +218,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
let scrollRef!: HTMLDivElement
|
||||
let slashPopoverRef!: HTMLDivElement
|
||||
let restoreEndOnFocus = true
|
||||
let savedCursor: number | null = null
|
||||
|
||||
const mirror = { input: false }
|
||||
const inset = 56
|
||||
@@ -590,7 +591,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const restoreFocus = () => {
|
||||
requestAnimationFrame(() => {
|
||||
const cursor = prompt.cursor() ?? promptLength(prompt.current())
|
||||
const cursor = savedCursor ?? prompt.cursor() ?? promptLength(prompt.current())
|
||||
editorRef.focus()
|
||||
setCursorPosition(editorRef, cursor)
|
||||
queueScroll()
|
||||
@@ -627,6 +628,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
|
||||
|
||||
const handleBlur = () => {
|
||||
savedCursor = currentCursor()
|
||||
closePopover()
|
||||
setComposing(false)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
|
||||
import { createEffect, For, onCleanup, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -8,6 +8,7 @@ import { getProjectAvatarVariant } from "@/context/layout"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
||||
|
||||
export type PromptProject = {
|
||||
name?: string
|
||||
@@ -101,6 +102,16 @@ export function createPromptProjectController(input: {
|
||||
setStore({ open: false, search: "", active: "" })
|
||||
input.controls().add(language.t("command.project.open"), server)
|
||||
}
|
||||
const setSearch = (value: string) => {
|
||||
const search = value.trim().toLowerCase()
|
||||
const first = input
|
||||
.controls()
|
||||
.available.find((project) => !search || displayName(project).toLowerCase().includes(search))
|
||||
setStore({
|
||||
search: value,
|
||||
active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
selected,
|
||||
@@ -127,16 +138,7 @@ export function createPromptProjectController(input: {
|
||||
}
|
||||
setStore({ open: false, search: "", active: "" })
|
||||
},
|
||||
setSearch(value: string) {
|
||||
const search = value.trim().toLowerCase()
|
||||
const first = input
|
||||
.controls()
|
||||
.available.find((project) => !search || displayName(project).toLowerCase().includes(search))
|
||||
setStore({
|
||||
search: value,
|
||||
active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key),
|
||||
})
|
||||
},
|
||||
setSearch,
|
||||
clearSearch() {
|
||||
setStore({ search: "", active: initialActive() })
|
||||
setTimeout(() => searchRef?.focus())
|
||||
@@ -170,6 +172,9 @@ export function createPromptProjectController(input: {
|
||||
focusSearch() {
|
||||
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
|
||||
},
|
||||
handleSearchKeydown(event: KeyboardEvent) {
|
||||
return handleDocumentSearchKeydown(searchRef, event, store.search, setSearch)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +248,13 @@ export function PromptProjectSelector(props: {
|
||||
return project ? props.controller.projectKey(project) : undefined
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.controller.open()) return
|
||||
const handler = (event: KeyboardEvent) => props.controller.handleSearchKeydown(event)
|
||||
document.addEventListener("keydown", handler, true)
|
||||
onCleanup(() => document.removeEventListener("keydown", handler, true))
|
||||
})
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
open={props.controller.open()}
|
||||
|
||||
@@ -399,7 +399,7 @@
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
line-height: 16px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createSignal, Show, type JSXElement } from "solid-js"
|
||||
import "./titlebar-tab-popover.css"
|
||||
|
||||
// Initial hover delay before the preview appears, per design.
|
||||
const OPEN_DELAY = 200
|
||||
const OPEN_DELAY = 400
|
||||
// Mouse-out delay: begin closing immediately (a brief exit animation plays).
|
||||
const CLOSE_DELAY = 0
|
||||
// After a preview closes, hovering a neighbouring tab within this window skips
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createEffect, createMemo, createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createServerProjects, ServerConnection, useServer } from "./server"
|
||||
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useServerHealth } from "@/utils/server-health"
|
||||
import { createServerSdkContext } from "./server-sdk"
|
||||
import { createServerSyncContext } from "./server-sync"
|
||||
@@ -127,6 +128,14 @@ function createServerCtx(
|
||||
}
|
||||
|
||||
const projectsList = createMemo(() => projects.list().map(enrich))
|
||||
const recentlyClosedList = createMemo(() => {
|
||||
const known = new Set(sync.data.project.map((project) => pathKey(project.worktree)))
|
||||
return projects
|
||||
.recentlyClosed()
|
||||
.filter((worktree) => known.has(pathKey(worktree)))
|
||||
.slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT)
|
||||
.map((worktree) => enrich({ worktree, expanded: false }))
|
||||
})
|
||||
|
||||
const isLocal =
|
||||
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
||||
@@ -139,6 +148,7 @@ function createServerCtx(
|
||||
projects: {
|
||||
...projects,
|
||||
list: projectsList,
|
||||
recentlyClosed: recentlyClosedList,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
|
||||
import { usePlatform } from "./platform"
|
||||
import { Project } from "@opencode-ai/sdk/v2"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { same } from "@/utils/same"
|
||||
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||
@@ -493,7 +494,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
const root = rootFor(project.worktree)
|
||||
if (root === project.worktree) continue
|
||||
|
||||
server.projects.close(project.worktree)
|
||||
server.projects.remove(project.worktree)
|
||||
|
||||
if (!seen.has(root)) {
|
||||
server.projects.open(root)
|
||||
@@ -613,6 +614,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
projects: {
|
||||
list,
|
||||
recentlyClosed: createMemo(() => {
|
||||
const known = new Set(serverSync().data.project.map((project) => pathKey(project.worktree)))
|
||||
return server.projects
|
||||
.recentlyClosed()
|
||||
.filter((worktree) => known.has(pathKey(worktree)))
|
||||
.slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT)
|
||||
.map((worktree) => enrich({ worktree, expanded: false }))
|
||||
}),
|
||||
open(directory: string) {
|
||||
const root = rootFor(directory)
|
||||
if (server.projects.list().find((x) => x.worktree === root)) return
|
||||
|
||||
@@ -99,7 +99,7 @@ describe("createServerProjects", () => {
|
||||
test("keeps active and explicit server buckets in one reactive store", () => {
|
||||
createRoot((dispose) => {
|
||||
const [scope] = createSignal(ServerScope.local)
|
||||
const [store, setStore] = createStore({ projects: {}, lastProject: {} })
|
||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
||||
const active = createServerProjects({ scope, store, setStore })
|
||||
const remote = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
|
||||
|
||||
@@ -115,6 +115,88 @@ describe("createServerProjects", () => {
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("tracks recently closed projects and drops them when reopened", () => {
|
||||
createRoot((dispose) => {
|
||||
const [scope] = createSignal(ServerScope.local)
|
||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
||||
const projects = createServerProjects({ scope, store, setStore })
|
||||
|
||||
projects.open("/a")
|
||||
projects.open("/b")
|
||||
projects.close("/a")
|
||||
expect(projects.recentlyClosed()).toEqual(["/a"])
|
||||
|
||||
projects.close("/b")
|
||||
expect(projects.recentlyClosed()).toEqual(["/b", "/a"])
|
||||
|
||||
projects.open("/a")
|
||||
expect(projects.recentlyClosed()).toEqual(["/b"])
|
||||
expect(projects.list()).toEqual([{ worktree: "/a", expanded: true }])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("remove drops a project without recording it as recently closed", () => {
|
||||
createRoot((dispose) => {
|
||||
const [scope] = createSignal(ServerScope.local)
|
||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
||||
const projects = createServerProjects({ scope, store, setStore })
|
||||
|
||||
projects.open("/repo/subdir")
|
||||
projects.remove("/repo/subdir")
|
||||
expect(projects.list()).toEqual([])
|
||||
expect(projects.recentlyClosed()).toEqual([])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("retains recently closed history beyond the visible display limit", () => {
|
||||
createRoot((dispose) => {
|
||||
const [scope] = createSignal(ServerScope.local)
|
||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
||||
const projects = createServerProjects({ scope, store, setStore })
|
||||
|
||||
// Closing 6 projects keeps all 6 in the store even though only 5 are displayed;
|
||||
// this prevents display-filtered entries from evicting still-visible ones.
|
||||
for (const dir of ["/1", "/2", "/3", "/4", "/5", "/6"]) {
|
||||
projects.open(dir)
|
||||
projects.close(dir)
|
||||
}
|
||||
expect(projects.recentlyClosed()).toEqual(["/6", "/5", "/4", "/3", "/2", "/1"])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("caps recently closed history at the store limit", () => {
|
||||
createRoot((dispose) => {
|
||||
const [scope] = createSignal(ServerScope.local)
|
||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
||||
const projects = createServerProjects({ scope, store, setStore })
|
||||
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
projects.open(`/p${i}`)
|
||||
projects.close(`/p${i}`)
|
||||
}
|
||||
expect(projects.recentlyClosed()).toHaveLength(16)
|
||||
expect(projects.recentlyClosed()[0]).toBe("/p20")
|
||||
expect(projects.recentlyClosed().at(-1)).toBe("/p5")
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("dedupes recently closed entries by normalized path", () => {
|
||||
createRoot((dispose) => {
|
||||
const [scope] = createSignal(ServerScope.local)
|
||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
||||
const projects = createServerProjects({ scope, store, setStore })
|
||||
|
||||
projects.close("/repo")
|
||||
projects.close("/repo/")
|
||||
expect(projects.recentlyClosed()).toEqual(["/repo/"])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("migrateCanonicalLocalServerState", () => {
|
||||
|
||||
@@ -2,12 +2,23 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { type Accessor, batch, createMemo } from "solid-js"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
type StoredProject = { worktree: string; expanded: boolean }
|
||||
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
|
||||
type ServerProjectState = { projects: Record<string, StoredProject[]>; lastProject: Record<string, string> }
|
||||
type ServerProjectState = {
|
||||
projects: Record<string, StoredProject[]>
|
||||
lastProject: Record<string, string>
|
||||
recentlyClosed: Record<string, string[]>
|
||||
}
|
||||
const HEALTH_POLL_INTERVAL_MS = 10_000
|
||||
// The store retains more history than is displayed. Consumers filter recently closed entries
|
||||
// against the live project list (dropping deleted projects) and then cap the visible count via
|
||||
// RECENTLY_CLOSED_DISPLAY_LIMIT. Retaining extra history ensures entries that are temporarily
|
||||
// filtered out do not evict still-visible ones from the persisted store.
|
||||
const RECENTLY_CLOSED_HISTORY_LIMIT = 16
|
||||
export const RECENTLY_CLOSED_DISPLAY_LIMIT = 5
|
||||
|
||||
export function normalizeServerUrl(input: string) {
|
||||
const trimmed = input.trim()
|
||||
@@ -72,19 +83,42 @@ export function createServerProjects<T extends ServerProjectState>(input: {
|
||||
}) {
|
||||
const setStore = input.setStore as unknown as SetStoreFunction<ServerProjectState>
|
||||
const current = () => input.store.projects[input.scope()] ?? []
|
||||
const currentClosed = () => input.store.recentlyClosed?.[input.scope()] ?? []
|
||||
const remove = (directory: string) => {
|
||||
setStore(
|
||||
"projects",
|
||||
input.scope(),
|
||||
current().filter((project) => project.worktree !== directory),
|
||||
)
|
||||
}
|
||||
return {
|
||||
list: current,
|
||||
recentlyClosed: currentClosed,
|
||||
remove,
|
||||
open(directory: string) {
|
||||
const scope = input.scope()
|
||||
const key = pathKey(directory)
|
||||
const closed = currentClosed()
|
||||
if (closed.some((worktree) => pathKey(worktree) === key)) {
|
||||
setStore(
|
||||
"recentlyClosed",
|
||||
scope,
|
||||
closed.filter((worktree) => pathKey(worktree) !== key),
|
||||
)
|
||||
}
|
||||
if (current().some((project) => project.worktree === directory)) return
|
||||
setStore("projects", scope, [{ worktree: directory, expanded: true }, ...current()])
|
||||
},
|
||||
// User-initiated close: removes the project and records it in recently closed.
|
||||
// Internal, non-user removals (e.g. sandbox/worktree normalization) should use remove().
|
||||
close(directory: string) {
|
||||
setStore(
|
||||
"projects",
|
||||
input.scope(),
|
||||
current().filter((project) => project.worktree !== directory),
|
||||
remove(directory)
|
||||
const key = pathKey(directory)
|
||||
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice(
|
||||
0,
|
||||
RECENTLY_CLOSED_HISTORY_LIMIT,
|
||||
)
|
||||
setStore("recentlyClosed", input.scope(), closed)
|
||||
},
|
||||
expand(directory: string) {
|
||||
const index = current().findIndex((project) => project.worktree === directory)
|
||||
@@ -235,6 +269,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
list: [] as StoredServer[],
|
||||
projects: {} as Record<string, StoredProject[]>,
|
||||
lastProject: {} as Record<string, string>,
|
||||
recentlyClosed: {} as Record<string, string[]>,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -604,6 +604,7 @@ export const dict = {
|
||||
"home.title": "Home",
|
||||
"home.projects": "Projects",
|
||||
"home.project.add": "Add project",
|
||||
"home.recentlyClosed": "Recently closed",
|
||||
"home.server.collapse": "Collapse server projects",
|
||||
"home.server.expand": "Expand server projects",
|
||||
"home.sessions.search.placeholder": "Search sessions",
|
||||
|
||||
@@ -270,6 +270,10 @@ export function NewHome() {
|
||||
})
|
||||
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
|
||||
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
|
||||
const recentlyClosed = createMemo(
|
||||
() => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
|
||||
)
|
||||
const homedir = createMemo(() => focusedSync().data.path.home ?? "")
|
||||
const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory))
|
||||
const newSessionProject = createMemo(
|
||||
() =>
|
||||
@@ -518,10 +522,13 @@ export function NewHome() {
|
||||
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
|
||||
<HomeProjectColumn
|
||||
projects={projects()}
|
||||
recentlyClosed={recentlyClosed()}
|
||||
homedir={homedir()}
|
||||
selected={selection()}
|
||||
focusServer={focusServer}
|
||||
selectProject={selectProject}
|
||||
openNewSession={openProjectNewSession}
|
||||
openRecentProject={(conn, directory) => addProjects(conn, [directory])}
|
||||
chooseProject={(conn) => void chooseProject(conn)}
|
||||
editProject={editProject}
|
||||
closeProject={(conn, directory) => {
|
||||
@@ -638,10 +645,13 @@ export function NewHome() {
|
||||
|
||||
function HomeProjectColumn(props: {
|
||||
projects: LocalProject[]
|
||||
recentlyClosed: LocalProject[]
|
||||
homedir: string
|
||||
selected: HomeProjectSelection
|
||||
focusServer: (server: ServerConnection.Any) => void
|
||||
selectProject: (server: ServerConnection.Any, directory: string) => void
|
||||
openNewSession: (server: ServerConnection.Any, directory: string) => void
|
||||
openRecentProject: (server: ServerConnection.Any, directory: string) => void
|
||||
chooseProject: (server: ServerConnection.Any) => void
|
||||
editProject: (server: ServerConnection.Any, project: LocalProject) => void
|
||||
closeProject: (server: ServerConnection.Any, directory: string) => void
|
||||
@@ -670,8 +680,10 @@ function HomeProjectColumn(props: {
|
||||
aria-label={props.language.t("home.projects")}
|
||||
>
|
||||
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
|
||||
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
|
||||
<Show when={global.servers.list().length === 1}>
|
||||
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
|
||||
<Show
|
||||
when={global.servers.list().length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}
|
||||
>
|
||||
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
@@ -691,7 +703,21 @@ function HomeProjectColumn(props: {
|
||||
when={global.servers.list().length > 1}
|
||||
fallback={
|
||||
<div class="pr-3">
|
||||
<HomeProjectList {...props} server={global.servers.list()[0]!} />
|
||||
<Show
|
||||
when={props.projects.length > 0}
|
||||
fallback={
|
||||
<HomeProjectEmpty
|
||||
server={global.servers.list()[0]!}
|
||||
recentlyClosed={props.recentlyClosed}
|
||||
homedir={props.homedir}
|
||||
chooseProject={props.chooseProject}
|
||||
openRecentProject={props.openRecentProject}
|
||||
language={props.language}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<HomeProjectList {...props} server={global.servers.list()[0]!} />
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -897,6 +923,79 @@ function HomeProjectList(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectEmpty(props: {
|
||||
server: ServerConnection.Any
|
||||
recentlyClosed: LocalProject[]
|
||||
homedir: string
|
||||
chooseProject: (server: ServerConnection.Any) => void
|
||||
openRecentProject: (server: ServerConnection.Any, directory: string) => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const global = useGlobal()
|
||||
const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
|
||||
return (
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
data-action="home-add-project-row"
|
||||
class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60 [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
||||
disabled={unreachable()}
|
||||
onClick={() => props.chooseProject(props.server)}
|
||||
>
|
||||
<IconV2 name="folder-add-left" size="small" />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
|
||||
</button>
|
||||
<Show when={props.recentlyClosed.length > 0}>
|
||||
<div class="mt-3 flex h-7 min-w-0 shrink-0 items-center pl-1.5 pr-3">
|
||||
<div class="text-v2-text-text-faint [font-weight:530]">{props.language.t("home.recentlyClosed")}</div>
|
||||
</div>
|
||||
<For each={props.recentlyClosed}>
|
||||
{(project) => (
|
||||
<HomeRecentlyClosedRow
|
||||
project={project}
|
||||
server={props.server}
|
||||
homedir={props.homedir}
|
||||
openRecentProject={props.openRecentProject}
|
||||
language={props.language}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeRecentlyClosedRow(props: {
|
||||
project: LocalProject
|
||||
server: ServerConnection.Any
|
||||
homedir: string
|
||||
openRecentProject: (server: ServerConnection.Any, directory: string) => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const global = useGlobal()
|
||||
const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
|
||||
const path = () => {
|
||||
const home = props.homedir
|
||||
const worktree = props.project.worktree
|
||||
if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
|
||||
return worktree
|
||||
}
|
||||
return (
|
||||
<TooltipV2 placement="right" value={path()}>
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-recently-closed-row"
|
||||
class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60`}
|
||||
disabled={unreachable()}
|
||||
onClick={() => props.openRecentProject(props.server, props.project.worktree)}
|
||||
>
|
||||
<HomeProjectAvatar project={props.project} outline />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
|
||||
</button>
|
||||
</TooltipV2>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectRow(props: {
|
||||
project: LocalProject
|
||||
server: ServerConnection.Any
|
||||
@@ -979,13 +1078,13 @@ function HomeProjectRow(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectAvatar(props: { project: LocalProject }) {
|
||||
function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) {
|
||||
const name = createMemo(() => displayName(props.project))
|
||||
return (
|
||||
<ProjectAvatar
|
||||
fallback={name()}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
src={props.outline ? undefined : getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={props.outline ? "outline" : getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1393,14 +1393,14 @@ export function MessageTimeline(props: {
|
||||
<button
|
||||
type="button"
|
||||
data-slot="session-title-parent"
|
||||
class="min-w-0 max-w-[40%] truncate px-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
|
||||
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
|
||||
onClick={navigateParent}
|
||||
>
|
||||
{parentTitle()}
|
||||
</button>
|
||||
<span
|
||||
data-slot="session-title-separator"
|
||||
class="-translate-y-[0.5px] px-1 text-[11px] font-medium text-v2-text-text-faint"
|
||||
class="-translate-y-[0.5px] pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
|
||||
aria-hidden="true"
|
||||
>
|
||||
/
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
|
||||
import { useSessionLayout } from "./session-layout"
|
||||
import { createSessionOwnership } from "./session-ownership"
|
||||
|
||||
@@ -26,9 +27,23 @@ export const useComposerCommands = () => {
|
||||
|
||||
const chooseModel = async () => {
|
||||
const owner = sessionOwnership.capture()
|
||||
const editor = document.querySelector<HTMLElement>('[data-component="prompt-input"]')
|
||||
const selection = window.getSelection()
|
||||
const cursor =
|
||||
editor && selection?.rangeCount && editor.contains(selection.anchorNode) ? getCursorPosition(editor) : null
|
||||
const restoreComposer = () => {
|
||||
// Kobalte restores focus during its teardown effect; defer past it so the
|
||||
// composer keeps focus and the caret returns to where the user left it.
|
||||
requestAnimationFrame(() => {
|
||||
const editor = document.querySelector<HTMLElement>('[data-component="prompt-input"]')
|
||||
if (!editor) return
|
||||
editor.focus()
|
||||
if (cursor !== null) setCursorPosition(editor, cursor)
|
||||
})
|
||||
}
|
||||
const { DialogSelectModel } = await import("@/components/dialog-select-model")
|
||||
owner.run(() => {
|
||||
void dialog.show(() => <DialogSelectModel model={local.model} />)
|
||||
void dialog.show(() => <DialogSelectModel model={local.model} />, restoreComposer)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
const editableSelector = "input, textarea, select, [contenteditable=''], [contenteditable='true']"
|
||||
|
||||
export function handleDocumentSearchKeydown(
|
||||
input: HTMLInputElement | undefined,
|
||||
event: KeyboardEvent,
|
||||
inputValue: string,
|
||||
setInputValue: (value: string) => void,
|
||||
) {
|
||||
if (!input) return false
|
||||
if (event.defaultPrevented || event.isComposing) return false
|
||||
if (event.target === input) return false
|
||||
if (event.target instanceof Element && event.target.closest(editableSelector)) return false
|
||||
|
||||
const action = searchKeyAction(event)
|
||||
if (!action) return false
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
input.focus()
|
||||
|
||||
const start = input.selectionStart ?? inputValue.length
|
||||
const end = input.selectionEnd ?? inputValue.length
|
||||
|
||||
if (action.type === "selectAll") {
|
||||
input.setSelectionRange(0, inputValue.length)
|
||||
return true
|
||||
}
|
||||
|
||||
if (action.type === "move") {
|
||||
moveSelection(input, inputValue, action.delta, event.shiftKey)
|
||||
return true
|
||||
}
|
||||
|
||||
if (action.type === "home") {
|
||||
setBoundarySelection(input, start, 0, event.shiftKey)
|
||||
return true
|
||||
}
|
||||
|
||||
if (action.type === "end") {
|
||||
setBoundarySelection(input, start, inputValue.length, event.shiftKey)
|
||||
return true
|
||||
}
|
||||
|
||||
if (action.type === "deleteBackward") {
|
||||
if (start !== end)
|
||||
return updateValue(input, inputValue.slice(0, start) + inputValue.slice(end), start, setInputValue)
|
||||
if (start === 0) return true
|
||||
return updateValue(input, inputValue.slice(0, start - 1) + inputValue.slice(end), start - 1, setInputValue)
|
||||
}
|
||||
|
||||
if (action.type === "deleteForward") {
|
||||
if (start !== end)
|
||||
return updateValue(input, inputValue.slice(0, start) + inputValue.slice(end), start, setInputValue)
|
||||
if (end === inputValue.length) return true
|
||||
return updateValue(input, inputValue.slice(0, start) + inputValue.slice(end + 1), start, setInputValue)
|
||||
}
|
||||
|
||||
return updateValue(
|
||||
input,
|
||||
inputValue.slice(0, start) + action.value + inputValue.slice(end),
|
||||
start + action.value.length,
|
||||
setInputValue,
|
||||
)
|
||||
}
|
||||
|
||||
function searchKeyAction(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "a") {
|
||||
return { type: "selectAll" } as const
|
||||
}
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return undefined
|
||||
if (event.key.length === 1) return { type: "insert", value: event.key } as const
|
||||
if (event.key === "Backspace") return { type: "deleteBackward" } as const
|
||||
if (event.key === "Delete") return { type: "deleteForward" } as const
|
||||
if (event.key === "ArrowLeft") return { type: "move", delta: -1 } as const
|
||||
if (event.key === "ArrowRight") return { type: "move", delta: 1 } as const
|
||||
if (event.key === "Home") return { type: "home" } as const
|
||||
if (event.key === "End") return { type: "end" } as const
|
||||
return undefined
|
||||
}
|
||||
|
||||
function moveSelection(input: HTMLInputElement, inputValue: string, delta: -1 | 1, extend: boolean) {
|
||||
const start = input.selectionStart ?? inputValue.length
|
||||
const end = input.selectionEnd ?? inputValue.length
|
||||
if (!extend && start !== end) {
|
||||
const caret = delta < 0 ? start : end
|
||||
input.setSelectionRange(caret, caret)
|
||||
return
|
||||
}
|
||||
|
||||
if (!extend) {
|
||||
const caret = Math.max(0, Math.min(inputValue.length, start + delta))
|
||||
input.setSelectionRange(caret, caret)
|
||||
return
|
||||
}
|
||||
|
||||
const backward = input.selectionDirection === "backward"
|
||||
const anchor = backward ? end : start
|
||||
const focus = backward ? start : end
|
||||
const next = Math.max(0, Math.min(inputValue.length, focus + delta))
|
||||
input.setSelectionRange(Math.min(anchor, next), Math.max(anchor, next), next < anchor ? "backward" : "forward")
|
||||
}
|
||||
|
||||
function setBoundarySelection(input: HTMLInputElement, anchor: number, focus: number, extend: boolean) {
|
||||
if (!extend) {
|
||||
input.setSelectionRange(focus, focus)
|
||||
return
|
||||
}
|
||||
input.setSelectionRange(Math.min(anchor, focus), Math.max(anchor, focus), focus < anchor ? "backward" : "forward")
|
||||
}
|
||||
|
||||
function updateValue(input: HTMLInputElement, value: string, caret: number, setInputValue: (value: string) => void) {
|
||||
input.value = value
|
||||
setInputValue(value)
|
||||
input.setSelectionRange(caret, caret)
|
||||
return true
|
||||
}
|
||||
+36
-35
@@ -8,11 +8,11 @@ The package is currently private to this workspace. Its API is designed around t
|
||||
|
||||
```ts
|
||||
// One execution
|
||||
yield* CodeMode.execute({ tools, code })
|
||||
yield * CodeMode.execute({ tools, code })
|
||||
|
||||
// A reusable runtime
|
||||
const runtime = CodeMode.make({ tools, limits })
|
||||
yield* runtime.execute(code)
|
||||
yield * runtime.execute(code)
|
||||
|
||||
// One agent-facing code tool
|
||||
const codeTool = runtime.agentTool()
|
||||
@@ -55,7 +55,9 @@ const runtime = CodeMode.make({
|
||||
},
|
||||
})
|
||||
|
||||
const result = yield* runtime.execute(`
|
||||
const result =
|
||||
yield *
|
||||
runtime.execute(`
|
||||
const order = await tools.orders.lookup({ id: "order_42" })
|
||||
return { id: order.id, needsAttention: order.status !== "complete" }
|
||||
`)
|
||||
@@ -72,8 +74,8 @@ Successful result values are JSON-safe data. A program that returns `undefined`,
|
||||
```ts
|
||||
const tool = Tool.make({
|
||||
description,
|
||||
input, // Effect Schema (validating) or JSON Schema (render-only)
|
||||
output, // optional; same choice
|
||||
input, // Effect Schema (validating) or JSON Schema (render-only)
|
||||
output, // optional; same choice
|
||||
run,
|
||||
})
|
||||
```
|
||||
@@ -89,13 +91,15 @@ The description and schemas are part of the model-visible tool contract. Keep de
|
||||
Use `CodeMode.execute` for a single execution:
|
||||
|
||||
```ts
|
||||
const result = yield* CodeMode.execute({
|
||||
tools: { orders: { lookup: lookupOrder } },
|
||||
code: `return await tools.orders.lookup({ id: "order_42" })`,
|
||||
limits: { maxToolCalls: 10 },
|
||||
onToolCallStart: (call) => Effect.logDebug("CodeMode tool started", call),
|
||||
onToolCallEnd: (call) => Effect.logDebug("CodeMode tool settled", call),
|
||||
})
|
||||
const result =
|
||||
yield *
|
||||
CodeMode.execute({
|
||||
tools: { orders: { lookup: lookupOrder } },
|
||||
code: `return await tools.orders.lookup({ id: "order_42" })`,
|
||||
limits: { maxToolCalls: 10 },
|
||||
onToolCallStart: (call) => Effect.logDebug("CodeMode tool started", call),
|
||||
onToolCallEnd: (call) => Effect.logDebug("CodeMode tool settled", call),
|
||||
})
|
||||
```
|
||||
|
||||
The Effect environment is inferred from the supplied tools. CodeMode does not erase service requirements introduced by tool implementations.
|
||||
@@ -110,10 +114,10 @@ const runtime = CodeMode.make({
|
||||
limits: { timeoutMs: 30_000 },
|
||||
})
|
||||
|
||||
runtime.catalog() // structured tool descriptions
|
||||
runtime.instructions() // model-facing syntax and tool guide
|
||||
runtime.catalog() // structured tool descriptions
|
||||
runtime.instructions() // model-facing syntax and tool guide
|
||||
runtime.execute(source) // ExecuteResult
|
||||
runtime.agentTool() // { name, description, input, output, execute }
|
||||
runtime.agentTool() // { name, description, input, output, execute }
|
||||
```
|
||||
|
||||
`catalog`, `instructions`, and `agentTool` are projections of the same configured tool tree. `agentTool().description` is exactly `instructions()`.
|
||||
@@ -222,10 +226,10 @@ CodeMode is an orchestration language, not a general JavaScript runtime.
|
||||
|
||||
The limits are exactly three knobs:
|
||||
|
||||
| Limit | Default | Bounds |
|
||||
| --- | ---: | --- |
|
||||
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
|
||||
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
|
||||
| Limit | Default | Bounds |
|
||||
| ---------------- | -------------------: | -------------------------------------------------------------------- |
|
||||
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
|
||||
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
|
||||
| `maxOutputBytes` | none - no truncation | Model-facing output: the serialized result value plus captured logs. |
|
||||
|
||||
No limit has a default, on purpose: execution budgets are host policy, not library policy - a host that wants a bound sets one; a host that can interrupt the execution fiber (as OpenCode does on user cancel) may set no timeout, and a host with its own tool-output truncation (as OpenCode has) may leave `maxOutputBytes` unset. A host with neither should set `maxOutputBytes`, or oversized results silently flood model context.
|
||||
@@ -254,28 +258,25 @@ Two interpreter internals are fixed constants rather than knobs: at most 8 tool
|
||||
|
||||
Failures are data:
|
||||
|
||||
| Kind | Meaning |
|
||||
| --- | --- |
|
||||
| `ParseError` | Source is empty or cannot be parsed. |
|
||||
| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. |
|
||||
| `UnknownTool` | A program referenced a tool the host did not provide. |
|
||||
| `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. |
|
||||
| `InvalidToolOutput` | Tool output failed schema decoding or safe-data copying. |
|
||||
| `InvalidDataValue` | Program data violated the plain-data contract (depth, circularity, blocked properties, non-data values). |
|
||||
| `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. |
|
||||
| `TimeoutExceeded` | Execution exceeded `timeoutMs`. |
|
||||
| `ToolFailure` | A tool refused or failed. |
|
||||
| `ExecutionFailure` | The program threw or another execution error occurred. |
|
||||
| Kind | Meaning |
|
||||
| ----------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `ParseError` | Source is empty or cannot be parsed. |
|
||||
| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. |
|
||||
| `UnknownTool` | A program referenced a tool the host did not provide. |
|
||||
| `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. |
|
||||
| `InvalidToolOutput` | Tool output failed schema decoding or safe-data copying. |
|
||||
| `InvalidDataValue` | Program data violated the plain-data contract (depth, circularity, blocked properties, non-data values). |
|
||||
| `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. |
|
||||
| `TimeoutExceeded` | Execution exceeded `timeoutMs`. |
|
||||
| `ToolFailure` | A tool refused or failed. |
|
||||
| `ExecutionFailure` | The program threw or another execution error occurred. |
|
||||
|
||||
Unknown host failures, defects, invalid outputs, and copying failures are sanitized. To return a safe operational refusal, fail with `toolError`:
|
||||
|
||||
```ts
|
||||
import { toolError } from "@opencode-ai/codemode"
|
||||
|
||||
run: ({ id }) =>
|
||||
authorized(id)
|
||||
? loadOrder(id)
|
||||
: Effect.fail(toolError("Order is unavailable"))
|
||||
run: ({ id }) => (authorized(id) ? loadOrder(id) : Effect.fail(toolError("Order is unavailable")))
|
||||
```
|
||||
|
||||
Only the supplied message is model-visible. The optional cause is never returned in `ExecuteResult`; hosts should perform any required internal logging before crossing this boundary.
|
||||
|
||||
+404
-374
@@ -39,6 +39,7 @@ package and was **deleted** in Wave 3 (done, see below).
|
||||
From issue #34787 and design discussion. Do not relitigate these casually.
|
||||
|
||||
### Core direction
|
||||
|
||||
- Generic CodeMode lives in its own package: `@opencode-ai/codemode` (repo scope convention;
|
||||
the issue's `@opencode/codemode` name was normalized to the `@opencode-ai/*` convention).
|
||||
- **Keep the hand-rolled interpreter.** No QuickJS/V8/sandbox-engine dependency. We own and
|
||||
@@ -52,6 +53,7 @@ From issue #34787 and design discussion. Do not relitigate these casually.
|
||||
products/blog posts) in code, comments, commit messages, or docs in this repo.
|
||||
|
||||
### MCP / tools
|
||||
|
||||
- The MCP adapter lives in OpenCode, not here. It converts MCP definitions into ordinary
|
||||
`Tool.make(...)` definitions and hands CodeMode a plain tool tree.
|
||||
- Permissions stay in the OpenCode adapter (each tool's `run` wraps the permission ask).
|
||||
@@ -61,8 +63,9 @@ From issue #34787 and design discussion. Do not relitigate these casually.
|
||||
`tools.<server>.<tool>` namespaces before handing them over.
|
||||
|
||||
### Discovery / search
|
||||
|
||||
- **Search only - no separate `describe`.** `tools.$codemode.search({ query?, namespace?,
|
||||
limit? })` over the final tool tree, owned by this package.
|
||||
limit? })` over the final tool tree, owned by this package.
|
||||
- Search result item shape: `{ path, description, signature }` in an `{ items, total }`
|
||||
wrapper. The `signature` string embeds the full input/output TypeScript types - in search
|
||||
results it is the pretty, JSDoc-annotated multiline form (Fix 7), so per-field schema
|
||||
@@ -82,6 +85,7 @@ From issue #34787 and design discussion. Do not relitigate these casually.
|
||||
- Tools without an output schema render `unknown` as their return type.
|
||||
|
||||
### Schemas / Tool.make
|
||||
|
||||
- `Tool.make` carries rich metadata so search can render real signatures.
|
||||
- Support **Effect Schema** (first-class, validating) and **JSON Schema** (initially
|
||||
render-only - used for TypeScript rendering; the adapter may validate on its own). Leave
|
||||
@@ -90,6 +94,7 @@ From issue #34787 and design discussion. Do not relitigate these casually.
|
||||
normalization for plugin authors can come later.
|
||||
|
||||
### Attachments / output
|
||||
|
||||
- **No `output.text/file/image` API in v1.** (Deleted in Wave 2.)
|
||||
- Tool calls return native structured payloads into the sandbox. Files/images emitted by
|
||||
child tools **never enter the sandbox** - the OpenCode adapter strips and accumulates them
|
||||
@@ -100,6 +105,7 @@ From issue #34787 and design discussion. Do not relitigate these casually.
|
||||
image bytes into context or drop attachments.
|
||||
|
||||
### Runtime behavior
|
||||
|
||||
- Limits are EXACTLY the three public knobs: `{ timeoutMs, maxToolCalls, maxOutputBytes }` -
|
||||
matching the original locked spec exactly. NO limit has a default (user direction, Fix 6
|
||||
for the first two; extended to `maxOutputBytes` in the truncation-layering fix below):
|
||||
@@ -129,7 +135,7 @@ From issue #34787 and design discussion. Do not relitigate these casually.
|
||||
- `console.*` is captured into `logs` on the result; the host appends them to model-facing
|
||||
output. Not a tool call; costs no tool budget.
|
||||
- Simple tool-call **start/end hooks** for nested progress: `onToolCallStart({ index, name,
|
||||
input })` and `onToolCallEnd({ index, name, input, durationMs, outcome, message? })`.
|
||||
input })` and `onToolCallEnd({ index, name, input, durationMs, outcome, message? })`.
|
||||
Interrupted calls fire no end event. No `CurrentToolCall` context service (removed in
|
||||
Wave 2).
|
||||
|
||||
@@ -148,8 +154,9 @@ and `bun run typecheck`; from `packages/opencode`, `bun run typecheck` and
|
||||
`test/tool/registry.test.ts`).
|
||||
|
||||
### Wave 0 - scaffold (done)
|
||||
|
||||
- `packages/codemode` created from the experiments implementation: `src/{index,codemode,tool,
|
||||
tool-error,tool-runtime}.ts`, README, AGENTS.md, tests.
|
||||
tool-error,tool-runtime}.ts`, README, AGENTS.md, tests.
|
||||
- `package.json`: name `@opencode-ai/codemode`, deps `acorn@8.15.0`, `typescript: catalog:`,
|
||||
`effect: catalog:` (both repos pin effect `4.0.0-beta.83`; opencode's effect patch only
|
||||
touches `unstable/httpapi`, which this package doesn't use).
|
||||
@@ -157,6 +164,7 @@ and `bun run typecheck`; from `packages/opencode`, `bun run typecheck` and
|
||||
Context.Service key string renamed to `@opencode-ai/codemode/CurrentToolCall`.
|
||||
|
||||
### Wave 1a - forgiving JS semantics (done)
|
||||
|
||||
Ported from the old opencode rune work; `test/parity.test.ts` (24 tests) is the acceptance
|
||||
spec. The seeded interpreter was deliberately strict; these behaviors replaced that:
|
||||
|
||||
@@ -173,6 +181,7 @@ spec. The seeded interpreter was deliberately strict; these behaviors replaced t
|
||||
null/undefined still throws (real JS throws too).
|
||||
|
||||
### Wave 1b-i - stdlib value types: Date, RegExp, Map, Set (done)
|
||||
|
||||
`src/values.ts` holds `SandboxDate/SandboxRegExp/SandboxMap/SandboxSet` (own module so both
|
||||
`codemode.ts` and `tool-runtime.ts` import without a cycle). Design:
|
||||
|
||||
@@ -202,19 +211,20 @@ spec. The seeded interpreter was deliberately strict; these behaviors replaced t
|
||||
interpolation renders `/regex/` and ISO dates directly.
|
||||
|
||||
### Wave 2 - API layer (done)
|
||||
|
||||
The package's public contract, reshaped for the Wave 3 adapter. 101 tests / 0 fail after this
|
||||
wave; both packages typecheck clean.
|
||||
|
||||
- **`Tool.make` schema flexibility** (`src/tool.ts`): `input`/`output` each accept an Effect
|
||||
Schema (validating, decoded both directions as before) OR a raw JSON Schema document
|
||||
(render-only - no validation, values pass through; rendering handles `$defs`/`definitions`
|
||||
+ `$ref`). `output` is **optional** -> signature renders `Promise<unknown>` and the host
|
||||
result is exposed as-is. Discrimination via `Schema.isSchema`. New helpers exported from
|
||||
`tool.ts`: `inputTypeScript`/`outputTypeScript`/`decodeInput`/`decodeOutput`/
|
||||
`jsonSchemaToTypeScript`; `tool-runtime.ts` consumes them (no direct `Schema.*` use there
|
||||
anymore). Types `JsonSchema`/`ToolSchema` exported from the index. Note: an empty
|
||||
`Schema.Struct({})` renders as `{ } | Array<unknown>` (effect's JSON Schema emission) -
|
||||
cosmetic, fixed in Wave 4.
|
||||
- `$ref`). `output` is **optional** -> signature renders `Promise<unknown>` and the host
|
||||
result is exposed as-is. Discrimination via `Schema.isSchema`. New helpers exported from
|
||||
`tool.ts`: `inputTypeScript`/`outputTypeScript`/`decodeInput`/`decodeOutput`/
|
||||
`jsonSchemaToTypeScript`; `tool-runtime.ts` consumes them (no direct `Schema.*` use there
|
||||
anymore). Types `JsonSchema`/`ToolSchema` exported from the index. Note: an empty
|
||||
`Schema.Struct({})` renders as `{ } | Array<unknown>` (effect's JSON Schema emission) -
|
||||
cosmetic, fixed in Wave 4.
|
||||
- **`output.*` API deleted**: `OutputItem`(+Schema), result `output` fields, the `output`
|
||||
global/namespace dispatch, `invokeOutput`/`outputItem`/helpers, interpreter output fields,
|
||||
instructions line, README section, seeded tests. AGENTS.md keeps a rephrased
|
||||
@@ -228,22 +238,23 @@ wave; both packages typecheck clean.
|
||||
(`ToolError`/`ToolRuntimeError` message, else "Tool execution failed"). Interrupted calls
|
||||
fire no end event (timeout kills the whole execution anyway).
|
||||
- **Limits collapse**: public `ExecutionLimits` = `{ timeoutMs?, maxToolCalls?,
|
||||
maxOutputBytes? }` (defaults 10_000 / 100 / 32_000). This wave kept the other knobs as
|
||||
maxOutputBytes? }` (defaults 10_000 / 100 / 32_000). This wave kept the other knobs as
|
||||
internal defaults reachable through an `@internal` `InternalExecutionLimits` type; Fix 5
|
||||
later deleted that type and the internal limit system entirely.
|
||||
- **`maxOutputBytes` truncation** (CodeMode-owned, never fails): applied via `boundOutput` in
|
||||
a final `Effect.map` over every result path (success/timeout/normalized failure). Oversized
|
||||
serialized values become truncated text + ` [result truncated: N bytes exceeds the M-byte
|
||||
output limit; return a smaller value]`; logs keep leading lines within the remaining budget
|
||||
+ `[logs truncated: showing K of N lines]`; result gains `truncated: true` (also added to
|
||||
`ExecuteResultSchema`). UTF-8-safe truncation (no split code points). (The in-sandbox
|
||||
`maxDataBytes` check that used to throw first on oversized raw values died in Fix 5 -
|
||||
truncation is now the only result-size mechanism.)
|
||||
output limit; return a smaller value]`; logs keep leading lines within the remaining budget
|
||||
- `[logs truncated: showing K of N lines]`; result gains `truncated: true` (also added to
|
||||
`ExecuteResultSchema`). UTF-8-safe truncation (no split code points). (The in-sandbox
|
||||
`maxDataBytes` check that used to throw first on oversized raw values died in Fix 5 -
|
||||
truncation is now the only result-size mechanism.)
|
||||
- **Search polish**: default limit 12 -> **10** (`defaultSearchLimit`); exact-path lookup - a
|
||||
trimmed query equal to one tool path (optionally `tools.`-prefixed) returns that tool alone
|
||||
(`total: 1`), bypassing ranking. Tokenization/ranking/shape unchanged.
|
||||
|
||||
### Wave 3 - OpenCode MCP adapter (done)
|
||||
|
||||
`packages/opencode/src/session/code-mode.ts` rewritten as a thin adapter over this package;
|
||||
the vendored rune interpreter is gone. Same `define(mcpTools, mcpDefs, servers)` signature, so
|
||||
`tools.ts` gating (flag on + MCP tools exist -> single `execute` tool, early-return suppresses
|
||||
@@ -257,7 +268,7 @@ per-MCP registration; MCP resource tools unaffected) is unchanged.
|
||||
invoked) - so signature rendering, the inline-vs-search switch, and `$codemode.search`
|
||||
availability all come from this package and stay consistent with execution.
|
||||
- **`run` path**: per-child permission ask first (`ctx.ask({ permission: entry.key, patterns:
|
||||
["*"], always: ["*"] })`, exactly the old gating; approving `execute` approves no child).
|
||||
["*"], always: ["*"] })`, exactly the old gating; approving `execute` approves no child).
|
||||
Denials and host failures are mapped to `toolError(message)` so they surface as safe,
|
||||
catchable in-program failures (MCP `isError` text propagates as `e.message`; without this
|
||||
they'd be sanitized to "Tool execution failed"). Dispatch reuses the ai-sdk wrapper from
|
||||
@@ -270,10 +281,10 @@ per-MCP registration; MCP resource tools unaffected) is unchanged.
|
||||
No handles, no `Result<T>` envelope, no base64 in the sandbox, no data-size tuning (the
|
||||
`maxDataBytes` budget that existed at the time was deleted in Fix 5).
|
||||
- **Execute result**: `{ output: formatValue(value) + trailing "Logs:" section (success AND
|
||||
error - logs are plain pre-formatted lines now), attachments: accumulated }` through the
|
||||
error - logs are plain pre-formatted lines now), attachments: accumulated }` through the
|
||||
existing `Tool.ExecuteResult.attachments` -> `message-v2.ts` vision plumbing; attachments
|
||||
ride on both success and error results. Diagnostic `suggestions` not already contained in
|
||||
the message are appended to error output. Native outer truncation stays on (adapter never
|
||||
the message are appended to error output. Native outer truncation stays on (adapter never
|
||||
sets `metadata.truncated`); CodeMode's own `maxOutputBytes` (32 KB default at the time)
|
||||
cut first - since the truncation-layering fix, native truncation is the only layer.
|
||||
Limits: `{ timeoutMs: 30_000 }` at the time (matched the default MCP request timeout);
|
||||
@@ -296,6 +307,7 @@ per-MCP registration; MCP resource tools unaffected) is unchanged.
|
||||
describe/`renderType`/`rankTools` tests died with the old design (58+17+24 -> 34+16).
|
||||
|
||||
### Wave 4 - instructions/prompting + polish (done)
|
||||
|
||||
Instructions are now the budgeted-catalog + prompting-guidance form; verified e2e against a
|
||||
real MCP config. Package still 101 tests / 0 fail; opencode adapter suites still 34 + 16; both
|
||||
packages typecheck clean.
|
||||
@@ -321,7 +333,7 @@ packages typecheck clean.
|
||||
exported); `CodeMode.execute` (one-shot) passes it too, preserving the
|
||||
`execute`==`make().execute` law. A speculative `tools.$codemode.search` call on a small
|
||||
catalog now succeeds instead of `UnknownTool`, and unknown-tool suggestions always point at
|
||||
search. Search is *advertised* in the instructions only when the inlined list is PARTIAL,
|
||||
search. Search is _advertised_ in the instructions only when the inlined list is PARTIAL,
|
||||
keeping small-catalog instructions tight.
|
||||
- **Prompting content** in `instructions()`, mapping 1:1 to the section 5 transcript failures:
|
||||
parse-string-results-as-JSON, return-small, console-for-intermediates, and
|
||||
@@ -340,7 +352,7 @@ packages typecheck clean.
|
||||
- **E2E (verified, headless)**: from the repo root with `OPENCODE_EXPERIMENTAL_CODE_MODE=1`,
|
||||
the scratch `.opencode/opencode.jsonc` (context7, github, playwright, sentry, memory,
|
||||
sequential-thinking; left uncommitted/as-is), and `bun packages/opencode/src/index.ts run
|
||||
--dangerously-skip-permissions -m opencode/claude-sonnet-4-5 "..."`. Confirmed: a single
|
||||
--dangerously-skip-permissions -m opencode/claude-sonnet-4-5 "..."`. Confirmed: a single
|
||||
`execute` tool registered alongside core tools (per-MCP registration suppressed; MCP
|
||||
resource tools unaffected); the live description read back as "Available tools (PARTIAL -
|
||||
56 of 88 shown; find the rest with tools.$codemode.search):" with correct per-namespace
|
||||
@@ -352,6 +364,7 @@ packages typecheck clean.
|
||||
images, output truncation.
|
||||
|
||||
### Wave 5 - Promise generalization (done)
|
||||
|
||||
First-class promise values in the interpreter; the direct-tool-call-only `Promise.all`
|
||||
restriction (and its bespoke AST checks) is gone. Package suite is 136 tests / 0 fail (35 new
|
||||
in `test/promise.test.ts`); adapter suites and both typechecks unchanged/green; the opencode
|
||||
@@ -495,7 +508,7 @@ adapter needed **no changes**.
|
||||
`tools.*`." (the second line drops the tools clause when the tree is empty).
|
||||
- **`## Workflow`**: numbered steps - find a tool via `tools.$codemode.search` -> read
|
||||
the `{ path, description, signature }` matches -> call by path -> `typeof res ===
|
||||
"string" ? JSON.parse(res) : res` -> return only the needed fields. When the catalog is
|
||||
"string" ? JSON.parse(res) : res` -> return only the needed fields. When the catalog is
|
||||
COMPLETE the search/read steps collapse into "Pick a tool from the list under
|
||||
`## Available tools`" and the steps renumber (4 instead of 5).
|
||||
- **`## Rules`**: call-by-exact-path; TEXT-is-JSON -> JSON.parse; return small (never raw
|
||||
@@ -526,70 +539,72 @@ adapter needed **no changes**.
|
||||
|
||||
**Fix 4 - token-budgeted catalog (was bytes)** (user direction: signatures need a token
|
||||
budget; namespaces must always be present):
|
||||
- `src/token.ts` added: copy of `@opencode-ai/core/util/token` (`round(chars / 4)`), so
|
||||
the package stays dependency-free; keep in sync if the core heuristic changes.
|
||||
- `DiscoveryOptions.maxInlineCatalogBytes` -> `maxInlineCatalogTokens` (default 4,000
|
||||
estimated tokens ~ the old 16,000 bytes at 4 chars/token - behavior parity, not a size
|
||||
reduction). `discoveryPlan` charges `estimate(catalogLine(tool))` per line; cheapest-first
|
||||
+ stop-on-first-miss unchanged at the time (stop-on-first-miss replaced by round-robin in
|
||||
|
||||
- `src/token.ts` added: copy of `@opencode-ai/core/util/token` (`round(chars / 4)`), so
|
||||
the package stays dependency-free; keep in sync if the core heuristic changes.
|
||||
- `DiscoveryOptions.maxInlineCatalogBytes` -> `maxInlineCatalogTokens` (default 4,000
|
||||
estimated tokens ~ the old 16,000 bytes at 4 chars/token - behavior parity, not a size
|
||||
reduction). `discoveryPlan` charges `estimate(catalogLine(tool))` per line; cheapest-first
|
||||
- stop-on-first-miss unchanged at the time (stop-on-first-miss replaced by round-robin in
|
||||
Fix 8). Namespace stub lines were and remain unbudgeted - every
|
||||
namespace always appears with its tool count, even at budget 0 (asserted in package and
|
||||
adapter tests).
|
||||
- Ripple: chars/4 rounding erases small line-length differences, so equal-cost lines fall
|
||||
to the lexicographic path tiebreak; the adapter's PARTIAL test now asserts the
|
||||
lexicographic tail (`op_99`) is excluded instead of `op_149`. Fixed-prose measurements
|
||||
(2026-07): preamble ~44 + Workflow ~146 + Rules ~362 + Syntax ~453 ~ 1,100 tokens fixed;
|
||||
worst-case net description ~ fixed + 4,000 ~ 5,100 estimated tokens.
|
||||
- Ripple: chars/4 rounding erases small line-length differences, so equal-cost lines fall
|
||||
to the lexicographic path tiebreak; the adapter's PARTIAL test now asserts the
|
||||
lexicographic tail (`op_99`) is excluded instead of `op_149`. Fixed-prose measurements
|
||||
(2026-07): preamble ~44 + Workflow ~146 + Rules ~362 + Syntax ~453 ~ 1,100 tokens fixed;
|
||||
worst-case net description ~ fixed + 4,000 ~ 5,100 estimated tokens.
|
||||
|
||||
**Fix 5 - internal limits removed** (user direction: only the three PUBLIC limits survive as
|
||||
configurable knobs; the internal limit system dies):
|
||||
- `ExecutionLimits` (`timeoutMs` 10_000 / `maxToolCalls` 100 / `maxOutputBytes` 32_000 at
|
||||
the time; Fix 6 later removed the first two defaults. Same validation: safe integers,
|
||||
timeoutMs >= 1, others >= 0, RangeError otherwise) is now
|
||||
the ENTIRE limit surface - exactly the shape section 2's original locked spec named.
|
||||
`ResolvedExecutionLimits` shrank to those three fields; the `@internal`
|
||||
`InternalExecutionLimits` type is deleted.
|
||||
- **Deleted outright**: `maxOperations` and the whole operation-budget machinery
|
||||
(`recordWork`/`recordOperation`/`budget.operations`, plus the `workUnits`/
|
||||
`cheapArrayMethods` cost helpers); `maxSourceBytes` (the pre-parse source-size check);
|
||||
`maxDataBytes` (every byte-accounting path: `runtimeValueBytes`, `boundedProgramValue`,
|
||||
the container-size caches (`containerSizes`/`objectCounts`), Map/Set incremental `bytes`
|
||||
fields in `values.ts`, string-growth `limitString` checks, tool-argument/result byte
|
||||
checks in `tool-runtime.ts`, and the final-result size check); `maxAuditBytes` (log and
|
||||
audit-trail byte accounting - `toolCalls` records and the start/end hooks are unchanged);
|
||||
`maxCollectionLength` (every array-length/object-field-count check - this knob was
|
||||
actively harmful: an MCP tool returning 20k rows failed). The `OperationLimitExceeded`
|
||||
and `AuditLimitExceeded` diagnostic kinds are gone from the `DiagnosticKind` union and
|
||||
`ExecuteResultSchema` (fine - the package is unreleased).
|
||||
- **Fixed constants, not knobs**: `TOOL_CALL_CONCURRENCY = 8` (codemode.ts; the fork
|
||||
semaphore) and `MAX_VALUE_DEPTH = 32` (tool-runtime.ts; the `copyIn` depth check - kept
|
||||
only because it produces a clearer error than a native stack-overflow RangeError; still
|
||||
`InvalidDataValue`). The `DataLimits` plumbing through `tool-runtime.ts` is gone -
|
||||
`copyIn(value, label)` needs no limits argument, and `ToolRuntime.make` takes just
|
||||
`(tools, maxToolCalls, hooks?, searchIndex?)`.
|
||||
- **Verified fact**: timeout interruption does NOT depend on the operation budget - the
|
||||
Effect fiber runtime auto-yields between interpreter steps, so `timeoutMs` interrupts
|
||||
even a pure `while (true) {}` loop (empirically verified: a 200ms timeout fired at
|
||||
~225ms with maxOperations set to MAX_SAFE_INTEGER before the deletion). A regression
|
||||
test in `codemode.test.ts` asserts exactly this (`while(true){}` + `timeoutMs: 200` ->
|
||||
`TimeoutExceeded`, elapsed well under a few seconds).
|
||||
- **Kept (correctness, not budgets)**: circular detection (`copyIn` walks +
|
||||
`rejectCircularInsertion` on mutations), plain-objects-only, blocked properties
|
||||
(`__proto__`/`constructor`/`prototype`), data-only checks, and all three public-limit
|
||||
behaviors unchanged.
|
||||
- Behavior deltas beyond the intended kills: in-sandbox structures deeper than 32 levels
|
||||
now fail at the data boundary (`copyIn`) instead of at construction; array index
|
||||
assignment allows any non-negative integer index (holes permitted, message now "must be
|
||||
a non-negative integer"); interpreter-produced deep/hostile structures that overflow the
|
||||
native stack during a walk still normalize to the existing "Execution exceeded the
|
||||
maximum nesting depth." data diagnostic - failures remain data everywhere.
|
||||
- Tests: deleted the knob-only tests (stdlib Map/Set collection-length growth x2,
|
||||
enumeration operation-budget, codemode maxDataBytes/maxSourceBytes/maxOperations/
|
||||
maxConcurrency-RangeError assertions, and the adapter's runaway-loop-via-operation-limit
|
||||
test - superseded by the package timeout regression test); rewrote the helpers that used
|
||||
`InternalExecutionLimits` as a convenience to plain `ExecutionLimits`
|
||||
(promise/enumeration/stdlib run helpers). Package suite: 154 pass / 0 fail; adapter
|
||||
suites: 34 + 16.
|
||||
|
||||
- `ExecutionLimits` (`timeoutMs` 10_000 / `maxToolCalls` 100 / `maxOutputBytes` 32_000 at
|
||||
the time; Fix 6 later removed the first two defaults. Same validation: safe integers,
|
||||
timeoutMs >= 1, others >= 0, RangeError otherwise) is now
|
||||
the ENTIRE limit surface - exactly the shape section 2's original locked spec named.
|
||||
`ResolvedExecutionLimits` shrank to those three fields; the `@internal`
|
||||
`InternalExecutionLimits` type is deleted.
|
||||
- **Deleted outright**: `maxOperations` and the whole operation-budget machinery
|
||||
(`recordWork`/`recordOperation`/`budget.operations`, plus the `workUnits`/
|
||||
`cheapArrayMethods` cost helpers); `maxSourceBytes` (the pre-parse source-size check);
|
||||
`maxDataBytes` (every byte-accounting path: `runtimeValueBytes`, `boundedProgramValue`,
|
||||
the container-size caches (`containerSizes`/`objectCounts`), Map/Set incremental `bytes`
|
||||
fields in `values.ts`, string-growth `limitString` checks, tool-argument/result byte
|
||||
checks in `tool-runtime.ts`, and the final-result size check); `maxAuditBytes` (log and
|
||||
audit-trail byte accounting - `toolCalls` records and the start/end hooks are unchanged);
|
||||
`maxCollectionLength` (every array-length/object-field-count check - this knob was
|
||||
actively harmful: an MCP tool returning 20k rows failed). The `OperationLimitExceeded`
|
||||
and `AuditLimitExceeded` diagnostic kinds are gone from the `DiagnosticKind` union and
|
||||
`ExecuteResultSchema` (fine - the package is unreleased).
|
||||
- **Fixed constants, not knobs**: `TOOL_CALL_CONCURRENCY = 8` (codemode.ts; the fork
|
||||
semaphore) and `MAX_VALUE_DEPTH = 32` (tool-runtime.ts; the `copyIn` depth check - kept
|
||||
only because it produces a clearer error than a native stack-overflow RangeError; still
|
||||
`InvalidDataValue`). The `DataLimits` plumbing through `tool-runtime.ts` is gone -
|
||||
`copyIn(value, label)` needs no limits argument, and `ToolRuntime.make` takes just
|
||||
`(tools, maxToolCalls, hooks?, searchIndex?)`.
|
||||
- **Verified fact**: timeout interruption does NOT depend on the operation budget - the
|
||||
Effect fiber runtime auto-yields between interpreter steps, so `timeoutMs` interrupts
|
||||
even a pure `while (true) {}` loop (empirically verified: a 200ms timeout fired at
|
||||
~225ms with maxOperations set to MAX_SAFE_INTEGER before the deletion). A regression
|
||||
test in `codemode.test.ts` asserts exactly this (`while(true){}` + `timeoutMs: 200` ->
|
||||
`TimeoutExceeded`, elapsed well under a few seconds).
|
||||
- **Kept (correctness, not budgets)**: circular detection (`copyIn` walks +
|
||||
`rejectCircularInsertion` on mutations), plain-objects-only, blocked properties
|
||||
(`__proto__`/`constructor`/`prototype`), data-only checks, and all three public-limit
|
||||
behaviors unchanged.
|
||||
- Behavior deltas beyond the intended kills: in-sandbox structures deeper than 32 levels
|
||||
now fail at the data boundary (`copyIn`) instead of at construction; array index
|
||||
assignment allows any non-negative integer index (holes permitted, message now "must be
|
||||
a non-negative integer"); interpreter-produced deep/hostile structures that overflow the
|
||||
native stack during a walk still normalize to the existing "Execution exceeded the
|
||||
maximum nesting depth." data diagnostic - failures remain data everywhere.
|
||||
- Tests: deleted the knob-only tests (stdlib Map/Set collection-length growth x2,
|
||||
enumeration operation-budget, codemode maxDataBytes/maxSourceBytes/maxOperations/
|
||||
maxConcurrency-RangeError assertions, and the adapter's runaway-loop-via-operation-limit
|
||||
test - superseded by the package timeout regression test); rewrote the helpers that used
|
||||
`InternalExecutionLimits` as a convenience to plain `ExecutionLimits`
|
||||
(promise/enumeration/stdlib run helpers). Package suite: 154 pass / 0 fail; adapter
|
||||
suites: 34 + 16.
|
||||
|
||||
**Fix 6 - no default timeout / tool-call cap** (user direction): `timeoutMs` and
|
||||
`maxToolCalls` lost their defaults (were 10_000 / 100) - absent now means no timeout /
|
||||
@@ -639,196 +654,200 @@ adapter suites: 34 + 16.
|
||||
**Fix 8 - condensed instructions + round-robin catalog fairness + plural-aware search**
|
||||
(user direction: the fixed instruction prose was too verbose; two discovery fixes ride
|
||||
along). All in `tool-runtime.ts`; no interpreter changes.
|
||||
- **Syntax section inverted**: the three dense allowlist lines (~453 estimated tokens)
|
||||
are replaced by four short lines (~188) built on "models already know JavaScript; name
|
||||
only what is unusual or missing": (1) standard modern JS works - functions/closures,
|
||||
destructuring, template literals, loops, try/catch, spread, optional chaining, the
|
||||
usual Array/String/Object/Math/JSON methods, plus Date/RegExp/Map/Set and
|
||||
Promise.all/allSettled/race/resolve/reject; (2) TypeScript type annotations are
|
||||
stripped before execution, decorators are not supported; (3) NOT supported (each fails
|
||||
with a message naming the alternative): classes, generators, for await...of,
|
||||
.then/.catch/.finally (use await with try/catch), `x instanceof Error` (caught errors
|
||||
are plain `{ name, message }` objects), splice; (4) the data-boundary note (Dates ->
|
||||
ISO strings; Map/Set/RegExp -> `{}`). Every claim was verified against the interpreter
|
||||
before writing: probed empirically - classes/generators/for-await/.then/.catch/
|
||||
.finally/`instanceof Error`/splice/decorators/BigInt/labeled statements/tagged
|
||||
templates/object getters all fail with clear diagnostics; TS annotations/`as`/
|
||||
interfaces/type aliases are stripped and TS **enums actually work** (transpileModule
|
||||
compiles them to an IIFE the interpreter runs), hence enums deliberately unmentioned.
|
||||
`supportedSyntaxMessage` (the in-diagnostic text in `codemode.ts`) is untouched.
|
||||
- **Workflow/Rules deduped**: the call-by-exact-path, JSON.parse-string-results, and
|
||||
return-small content now lives ONLY in the numbered Workflow steps (with their
|
||||
compliance-driving justifications inline: "most tools return JSON as a string", "raw
|
||||
payloads get truncated and waste context"); Rules keeps only bullets adding new
|
||||
content - filter/aggregate collections in code, console.* intermediates (logs ride
|
||||
back), Promise.all parallelism, Object.keys/for...in enumeration, browse-namespace
|
||||
(PARTIAL only), and the media rule compressed to one line. The no-.then/.catch
|
||||
guidance moved to the Syntax not-supported line. Content upgrades: the PARTIAL search
|
||||
step gained query-style guidance (`- short phrases like "list issues" work best`; a
|
||||
clearly-a-query-string example, not a tool name), and the exact-path guidance is now
|
||||
"call it with the result's `path` as-is (never guess segments)" / COMPLETE: "use it
|
||||
as-is rather than guessing segments".
|
||||
- **Fixed-prose measurements** (instructions split on `"\n## "`, catalog budget 0,
|
||||
bytes/3.7 - same method as Fix 4; chars/4 in parentheses):
|
||||
preamble 44 -> 44 (41 -> 41), Workflow 146 -> 187 (135 -> 171), Rules 362 -> 191
|
||||
(332 -> 176), Syntax 453 -> 188 (419 -> 174); fixed prose total 1,005 -> 610 (927 -> 562),
|
||||
~ 40% reduction with no behavioral content dropped. Workflow grew slightly because it
|
||||
absorbed the deduped parse/return-small justifications.
|
||||
- **Round-robin namespace inlining** (`discoveryPlan`): the ported stop-on-first-miss
|
||||
behavior (alphabetically-late namespaces starved to "none shown" while an early
|
||||
namespace inlines everything) is replaced by round-robin fairness - in each round
|
||||
(namespaces alphabetical), every namespace still holding un-inlined tools attempts to
|
||||
place its next-cheapest line against the shared token budget; a namespace whose next
|
||||
line does not fit is done while the others keep going; stop when all are done. Every
|
||||
namespace gets some representation before any namespace gets everything. Kept:
|
||||
`estimate` (chars/4) budget accounting, unbudgeted namespace stub lines, per-namespace
|
||||
`(N tools)`/`(N tools, K shown)`/`(N tools, none shown)` labels, COMPLETE vs PARTIAL
|
||||
header, alphabetical namespace order in the output, cheapest-first within each
|
||||
namespace's shown set.
|
||||
- **Plural/singular search fix**: `tokenize`d terms matched one-directionally (term must
|
||||
be substring of indexed text), so query "issues" missed a tool whose text only says
|
||||
"issue". Now each term expands to `termForms` - the term plus naive singular variants
|
||||
(trailing "es" stripped when length > 3, trailing "s" when length > 2) - and each of
|
||||
the four field checks passes when ANY form matches. Weights, exact-path lookup, and
|
||||
namespace scoping untouched. A true plural path match still outranks a singular-only
|
||||
description match (path substring 8 + searchable 2 > description 4 + searchable 2).
|
||||
- **Tests**: package instruction/structure assertions updated to the new text; new
|
||||
syntax-section test (leads with "Standard modern JavaScript works", names the
|
||||
verified not-supported list, keeps the data-boundary note); the budget-exhaustion
|
||||
test rewritten to assert the new fairness (alpha.expensive not fitting must NOT
|
||||
prevent beta.cheap from showing: PARTIAL 2 of 3, `- beta (1 tool)` fully shown); new
|
||||
plural/singular test (query "issues" finds a singular-only tool; ranking still
|
||||
prefers the true "issues" path match). Adapter: description assertions updated; the
|
||||
large-catalog PARTIAL test now asserts `zeta_only_tool` IS shown (`- zeta (1 tool)` +
|
||||
its inlined line) - it was "none shown" under starvation. README updated (budgeted
|
||||
catalog paragraph -> round-robin; search paragraph -> singular variants;
|
||||
instructions-structure paragraph -> new section contents). Package suite: 169 pass /
|
||||
0 fail; adapter suites: 34 + 16.
|
||||
|
||||
- **Syntax section inverted**: the three dense allowlist lines (~453 estimated tokens)
|
||||
are replaced by four short lines (~188) built on "models already know JavaScript; name
|
||||
only what is unusual or missing": (1) standard modern JS works - functions/closures,
|
||||
destructuring, template literals, loops, try/catch, spread, optional chaining, the
|
||||
usual Array/String/Object/Math/JSON methods, plus Date/RegExp/Map/Set and
|
||||
Promise.all/allSettled/race/resolve/reject; (2) TypeScript type annotations are
|
||||
stripped before execution, decorators are not supported; (3) NOT supported (each fails
|
||||
with a message naming the alternative): classes, generators, for await...of,
|
||||
.then/.catch/.finally (use await with try/catch), `x instanceof Error` (caught errors
|
||||
are plain `{ name, message }` objects), splice; (4) the data-boundary note (Dates ->
|
||||
ISO strings; Map/Set/RegExp -> `{}`). Every claim was verified against the interpreter
|
||||
before writing: probed empirically - classes/generators/for-await/.then/.catch/
|
||||
.finally/`instanceof Error`/splice/decorators/BigInt/labeled statements/tagged
|
||||
templates/object getters all fail with clear diagnostics; TS annotations/`as`/
|
||||
interfaces/type aliases are stripped and TS **enums actually work** (transpileModule
|
||||
compiles them to an IIFE the interpreter runs), hence enums deliberately unmentioned.
|
||||
`supportedSyntaxMessage` (the in-diagnostic text in `codemode.ts`) is untouched.
|
||||
- **Workflow/Rules deduped**: the call-by-exact-path, JSON.parse-string-results, and
|
||||
return-small content now lives ONLY in the numbered Workflow steps (with their
|
||||
compliance-driving justifications inline: "most tools return JSON as a string", "raw
|
||||
payloads get truncated and waste context"); Rules keeps only bullets adding new
|
||||
content - filter/aggregate collections in code, console.\* intermediates (logs ride
|
||||
back), Promise.all parallelism, Object.keys/for...in enumeration, browse-namespace
|
||||
(PARTIAL only), and the media rule compressed to one line. The no-.then/.catch
|
||||
guidance moved to the Syntax not-supported line. Content upgrades: the PARTIAL search
|
||||
step gained query-style guidance (`- short phrases like "list issues" work best`; a
|
||||
clearly-a-query-string example, not a tool name), and the exact-path guidance is now
|
||||
"call it with the result's `path` as-is (never guess segments)" / COMPLETE: "use it
|
||||
as-is rather than guessing segments".
|
||||
- **Fixed-prose measurements** (instructions split on `"\n## "`, catalog budget 0,
|
||||
bytes/3.7 - same method as Fix 4; chars/4 in parentheses):
|
||||
preamble 44 -> 44 (41 -> 41), Workflow 146 -> 187 (135 -> 171), Rules 362 -> 191
|
||||
(332 -> 176), Syntax 453 -> 188 (419 -> 174); fixed prose total 1,005 -> 610 (927 -> 562),
|
||||
~ 40% reduction with no behavioral content dropped. Workflow grew slightly because it
|
||||
absorbed the deduped parse/return-small justifications.
|
||||
- **Round-robin namespace inlining** (`discoveryPlan`): the ported stop-on-first-miss
|
||||
behavior (alphabetically-late namespaces starved to "none shown" while an early
|
||||
namespace inlines everything) is replaced by round-robin fairness - in each round
|
||||
(namespaces alphabetical), every namespace still holding un-inlined tools attempts to
|
||||
place its next-cheapest line against the shared token budget; a namespace whose next
|
||||
line does not fit is done while the others keep going; stop when all are done. Every
|
||||
namespace gets some representation before any namespace gets everything. Kept:
|
||||
`estimate` (chars/4) budget accounting, unbudgeted namespace stub lines, per-namespace
|
||||
`(N tools)`/`(N tools, K shown)`/`(N tools, none shown)` labels, COMPLETE vs PARTIAL
|
||||
header, alphabetical namespace order in the output, cheapest-first within each
|
||||
namespace's shown set.
|
||||
- **Plural/singular search fix**: `tokenize`d terms matched one-directionally (term must
|
||||
be substring of indexed text), so query "issues" missed a tool whose text only says
|
||||
"issue". Now each term expands to `termForms` - the term plus naive singular variants
|
||||
(trailing "es" stripped when length > 3, trailing "s" when length > 2) - and each of
|
||||
the four field checks passes when ANY form matches. Weights, exact-path lookup, and
|
||||
namespace scoping untouched. A true plural path match still outranks a singular-only
|
||||
description match (path substring 8 + searchable 2 > description 4 + searchable 2).
|
||||
- **Tests**: package instruction/structure assertions updated to the new text; new
|
||||
syntax-section test (leads with "Standard modern JavaScript works", names the
|
||||
verified not-supported list, keeps the data-boundary note); the budget-exhaustion
|
||||
test rewritten to assert the new fairness (alpha.expensive not fitting must NOT
|
||||
prevent beta.cheap from showing: PARTIAL 2 of 3, `- beta (1 tool)` fully shown); new
|
||||
plural/singular test (query "issues" finds a singular-only tool; ranking still
|
||||
prefers the true "issues" path match). Adapter: description assertions updated; the
|
||||
large-catalog PARTIAL test now asserts `zeta_only_tool` IS shown (`- zeta (1 tool)` +
|
||||
its inlined line) - it was "none shown" under starvation. README updated (budgeted
|
||||
catalog paragraph -> round-robin; search paragraph -> singular variants;
|
||||
instructions-structure paragraph -> new section contents). Package suite: 169 pass /
|
||||
0 fail; adapter suites: 34 + 16.
|
||||
|
||||
**Fix 9 - prompting trims per user review of Fix 8** (user reviewed the condensed
|
||||
instructions and directed further cuts):
|
||||
- Default `maxInlineCatalogTokens` 4,000 -> **2,000** (user wants ~2k tokens of signatures
|
||||
auto-inlined; round-robin fairness from Fix 8 spreads it across all namespaces).
|
||||
- Console rule and files/images rule DROPPED from `## Rules`. Replaced by a single
|
||||
`unknown`-treatment warning: "A result typed `Promise<unknown>` has no guaranteed
|
||||
shape - verify what actually came back before relying on its fields." (Deliberately
|
||||
does NOT suggest console.log - user review: naming it there nudges models to log AND
|
||||
return the same data; the prompt stays console-neutral, neither for nor against.)
|
||||
The media-stripping MECHANISM is unchanged and still tested; only the prose about it
|
||||
is gone - the `[N images attached]` marker is self-explanatory in context.
|
||||
- Kept as-is per user: the JSON.parse workflow step (maps to the original motivating
|
||||
transcript failure; NOT copied from prior art - see section 5 note), the browse-namespace rule
|
||||
(undecided), no no-fetch/ambient-authority rule added (proposed, not approved).
|
||||
- Explicitly REJECTED for now: auto-parsing JSON-looking text results at the adapter
|
||||
boundary ("could get weird" - type flips, program-sees vs tool-sent divergence). Logged
|
||||
as a next-iteration follow-up below.
|
||||
|
||||
- Default `maxInlineCatalogTokens` 4,000 -> **2,000** (user wants ~2k tokens of signatures
|
||||
auto-inlined; round-robin fairness from Fix 8 spreads it across all namespaces).
|
||||
- Console rule and files/images rule DROPPED from `## Rules`. Replaced by a single
|
||||
`unknown`-treatment warning: "A result typed `Promise<unknown>` has no guaranteed
|
||||
shape - verify what actually came back before relying on its fields." (Deliberately
|
||||
does NOT suggest console.log - user review: naming it there nudges models to log AND
|
||||
return the same data; the prompt stays console-neutral, neither for nor against.)
|
||||
The media-stripping MECHANISM is unchanged and still tested; only the prose about it
|
||||
is gone - the `[N images attached]` marker is self-explanatory in context.
|
||||
- Kept as-is per user: the JSON.parse workflow step (maps to the original motivating
|
||||
transcript failure; NOT copied from prior art - see section 5 note), the browse-namespace rule
|
||||
(undecided), no no-fetch/ambient-authority rule added (proposed, not approved).
|
||||
- Explicitly REJECTED for now: auto-parsing JSON-looking text results at the adapter
|
||||
boundary ("could get weird" - type flips, program-sees vs tool-sent divergence). Logged
|
||||
as a next-iteration follow-up below.
|
||||
|
||||
**DSL-expansion pass - interpreter-surface batch from section 4** (the deferred medium-tier JS
|
||||
parity items, done as one focused pass; no public API or limit changes):
|
||||
- **`instanceof` + real Error values**: the `errorConstructors` names (`Error`,
|
||||
`TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, `URIError`) are
|
||||
bound globals (`ErrorConstructorReference`, callable with or without `new`; `typeof` ->
|
||||
`"function"`). Error values stay the same plain `{ name, message }` null-prototype
|
||||
objects as before - the constructor name additionally rides on a NON-ENUMERABLE symbol
|
||||
key (`ErrorBrand`), which every `Object.entries`-based walk (copyIn/copyOut, spread,
|
||||
JSON.stringify) is blind to, so serialization is byte-identical to the old shape and the
|
||||
brand is lost on spread/boundary copies exactly like JS loses the prototype.
|
||||
`caughtErrorValue` produces `{ name, message }` wrappers via `createErrorValue`, so
|
||||
caught interpreter AND tool failures are `instanceof Error` and carry the `name` the
|
||||
equivalent real-JS failure would have (follow-up fix, user-directed - "closest to real
|
||||
JS"): `InterpreterRuntimeError` gained an `errorName` field ("Error" default) set
|
||||
fluently at throw sites via `.as(name)` - `JSON.parse` failures are `"SyntaxError"` (and
|
||||
now include the engine's position detail in the message; safe - derived from the
|
||||
program-supplied string), invalid regex patterns/flags `"SyntaxError"`, unknown
|
||||
identifiers and TDZ access `"ReferenceError"`, assignment to a constant `"TypeError"`,
|
||||
a bad `normalize` form `"RangeError"`; a host Error reaching the catch path directly
|
||||
keeps its own name when it is one of the standard seven. Tool failures and everything
|
||||
without a specific analogue stay `"Error"` - internal class names never leak. Specific
|
||||
names satisfy the specific `instanceof` (`e instanceof SyntaxError`), matching JS.
|
||||
The operator is handled in `evaluateBinaryExpression`
|
||||
BEFORE the data-only operand check (like `typeof`, it observes any lhs - promises and
|
||||
functions included); recognized rhs: the error constructors (a specific type matches its
|
||||
own brand or `Error`, never a sibling), `Date`/`RegExp`/`Map`/`Set` (sandbox classes),
|
||||
`Array`, `Object` (any object/function-ish value), `Promise` (`SandboxPromise`), and
|
||||
`Number`/`String`/`Boolean` (always false - no boxed values exist); anything else is a
|
||||
catchable error naming the recognized constructors.
|
||||
- **Array methods**: `splice` (mutating, returns the removed elements; insertions run
|
||||
`rejectCircularInsertion` like push/unshift; one-arg form removes to the end, undefined
|
||||
delete count removes nothing), `fill` (circular-checked value) and `copyWithin`
|
||||
(host-delegated), and `keys`/`values`/`entries` returning **arrays** (the Map/Set
|
||||
convention - for...of and spread work either way). The `retryableArrayMethods`
|
||||
"rewrite using map/filter" hint set emptied out and was deleted with its branch; unknown
|
||||
array properties still read `undefined`.
|
||||
- **String methods**: `localeCompare(that)` (locale/options arguments ignored - host
|
||||
default locale; the dominant use is a sort comparator), `normalize(form?)` (invalid form
|
||||
-> catchable error naming the four valid forms), `trimLeft`/`trimRight` as
|
||||
trimStart/trimEnd aliases.
|
||||
- **Actionable regex failures**: `toHostRegex` and `constructRegExp` now show the
|
||||
offending pattern (or flags) plus the engine reason (deduped "Invalid regular
|
||||
expression:" prefix via `regexFailureReason`) and a shared escaping hint
|
||||
(`escapeRegexHint`); flags failures list the valid flag letters; the
|
||||
replaceAll/matchAll missing-`g` errors spell out the exact `/pattern/g` to write and
|
||||
the single-match alternative.
|
||||
- **copyIn split (the important one)**: `copyIn(value, label, preserveSandboxValues =
|
||||
false)` - recursion moved to a private `copyBounded`; `boundedData` (every intra-sandbox
|
||||
checkpoint: `Object.*` helpers, coercion/Array.from/join inputs, template
|
||||
interpolation, expression-result checkpoints) is now `copyIn(value, label, true)`,
|
||||
which passes `SandboxDate`/`SandboxRegExp`/`SandboxMap`/`SandboxSet` through **by
|
||||
reference as leaves** (contents not walked - Map/Set members are validated at their
|
||||
mutation sites) while keeping the depth (`MAX_VALUE_DEPTH`), circularity,
|
||||
plain-objects-only, blocked-property, and data-only checks; un-awaited promises keep
|
||||
the await-hinting rejection in BOTH modes (deliberate - JS-parity pass-through was
|
||||
considered and skipped to preserve the nudge). The HOST boundary (final result,
|
||||
tool-call arguments, `JSON.stringify`, tool-result intake) uses the default mode and
|
||||
still serializes JSON forms (Date -> ISO, RegExp/Map/Set -> `{}`); host instances met on
|
||||
the preserving path are defensively wrapped into sandbox equivalents. Ripple: the
|
||||
`Object.*` helpers treat sandbox values as empty objects (`Object.keys(map)` -> `[]`,
|
||||
assign sources contribute nothing, hasOwn -> false - JS has no own enumerable props
|
||||
there), so interpreter internals (`.map`/`.time`/`.regex`) can never leak; the
|
||||
template-literal sandbox carve-out collapsed into `boundedData`. Object/array spread
|
||||
already preserved instances (reference copies, no checkpoint) - now tested.
|
||||
- **Console formatting**: `formatConsoleArgument` is total and deep
|
||||
(`formatConsoleValue`): numbers render via `String` (`NaN`/`Infinity`/`-Infinity`
|
||||
literally - never the JSON `null`; finite numbers match their JSON form), nested
|
||||
strings are JSON-quoted, sandbox values keep their friendly forms at ANY depth (ISO
|
||||
date, `/regex/flags`, `Map(n) [...]`, `Set(n) [...]`), opaque references become
|
||||
in-place `[CodeMode reference]` markers instead of collapsing the whole argument,
|
||||
cycles render `[Circular]` (reachable via Map/Set members, which mutation never
|
||||
checkpoints), and depth beyond `MAX_CONSOLE_DEPTH = 32` (fixed constant, not a knob)
|
||||
degrades to `...` - console can no longer fail a program. `console.table` guards with
|
||||
`containsOpaqueReference` (sandbox cells render, e.g. ISO dates) and its row/cell
|
||||
walkers treat sandbox values as scalar cells.
|
||||
- **Prose**: the instructions Syntax not-supported line dropped its `instanceof
|
||||
Error`/splice mentions (nothing else reworded); README updated (checkpoint
|
||||
preservation vs boundary serialization, error values/`instanceof`, new array/string
|
||||
methods, regex-failure behavior); `supportedSyntaxMessage` left untouched (it lists
|
||||
supported syntax, was already non-exhaustive, and stays accurate).
|
||||
- **Tests**: package suite 169 -> 209 (parity: Error/instanceof + real-JS error-name
|
||||
coverage, splice/fill/copyWithin/keys/values/entries, localeCompare/normalize/trim-alias
|
||||
describes; stdlib: checkpoint survival incl. tool-arg boundary pinning, stdlib
|
||||
`instanceof`, regex-message assertions; codemode: NaN/Infinity + nested/cyclic console
|
||||
rendering, table cells, caught-tool-failure `instanceof`); adapter suites unchanged
|
||||
(34 + 16, green); both packages `tsgo --noEmit` clean.
|
||||
|
||||
- **`instanceof` + real Error values**: the `errorConstructors` names (`Error`,
|
||||
`TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, `URIError`) are
|
||||
bound globals (`ErrorConstructorReference`, callable with or without `new`; `typeof` ->
|
||||
`"function"`). Error values stay the same plain `{ name, message }` null-prototype
|
||||
objects as before - the constructor name additionally rides on a NON-ENUMERABLE symbol
|
||||
key (`ErrorBrand`), which every `Object.entries`-based walk (copyIn/copyOut, spread,
|
||||
JSON.stringify) is blind to, so serialization is byte-identical to the old shape and the
|
||||
brand is lost on spread/boundary copies exactly like JS loses the prototype.
|
||||
`caughtErrorValue` produces `{ name, message }` wrappers via `createErrorValue`, so
|
||||
caught interpreter AND tool failures are `instanceof Error` and carry the `name` the
|
||||
equivalent real-JS failure would have (follow-up fix, user-directed - "closest to real
|
||||
JS"): `InterpreterRuntimeError` gained an `errorName` field ("Error" default) set
|
||||
fluently at throw sites via `.as(name)` - `JSON.parse` failures are `"SyntaxError"` (and
|
||||
now include the engine's position detail in the message; safe - derived from the
|
||||
program-supplied string), invalid regex patterns/flags `"SyntaxError"`, unknown
|
||||
identifiers and TDZ access `"ReferenceError"`, assignment to a constant `"TypeError"`,
|
||||
a bad `normalize` form `"RangeError"`; a host Error reaching the catch path directly
|
||||
keeps its own name when it is one of the standard seven. Tool failures and everything
|
||||
without a specific analogue stay `"Error"` - internal class names never leak. Specific
|
||||
names satisfy the specific `instanceof` (`e instanceof SyntaxError`), matching JS.
|
||||
The operator is handled in `evaluateBinaryExpression`
|
||||
BEFORE the data-only operand check (like `typeof`, it observes any lhs - promises and
|
||||
functions included); recognized rhs: the error constructors (a specific type matches its
|
||||
own brand or `Error`, never a sibling), `Date`/`RegExp`/`Map`/`Set` (sandbox classes),
|
||||
`Array`, `Object` (any object/function-ish value), `Promise` (`SandboxPromise`), and
|
||||
`Number`/`String`/`Boolean` (always false - no boxed values exist); anything else is a
|
||||
catchable error naming the recognized constructors.
|
||||
- **Array methods**: `splice` (mutating, returns the removed elements; insertions run
|
||||
`rejectCircularInsertion` like push/unshift; one-arg form removes to the end, undefined
|
||||
delete count removes nothing), `fill` (circular-checked value) and `copyWithin`
|
||||
(host-delegated), and `keys`/`values`/`entries` returning **arrays** (the Map/Set
|
||||
convention - for...of and spread work either way). The `retryableArrayMethods`
|
||||
"rewrite using map/filter" hint set emptied out and was deleted with its branch; unknown
|
||||
array properties still read `undefined`.
|
||||
- **String methods**: `localeCompare(that)` (locale/options arguments ignored - host
|
||||
default locale; the dominant use is a sort comparator), `normalize(form?)` (invalid form
|
||||
-> catchable error naming the four valid forms), `trimLeft`/`trimRight` as
|
||||
trimStart/trimEnd aliases.
|
||||
- **Actionable regex failures**: `toHostRegex` and `constructRegExp` now show the
|
||||
offending pattern (or flags) plus the engine reason (deduped "Invalid regular
|
||||
expression:" prefix via `regexFailureReason`) and a shared escaping hint
|
||||
(`escapeRegexHint`); flags failures list the valid flag letters; the
|
||||
replaceAll/matchAll missing-`g` errors spell out the exact `/pattern/g` to write and
|
||||
the single-match alternative.
|
||||
- **copyIn split (the important one)**: `copyIn(value, label, preserveSandboxValues =
|
||||
false)` - recursion moved to a private `copyBounded`; `boundedData` (every intra-sandbox
|
||||
checkpoint: `Object.*` helpers, coercion/Array.from/join inputs, template
|
||||
interpolation, expression-result checkpoints) is now `copyIn(value, label, true)`,
|
||||
which passes `SandboxDate`/`SandboxRegExp`/`SandboxMap`/`SandboxSet` through **by
|
||||
reference as leaves** (contents not walked - Map/Set members are validated at their
|
||||
mutation sites) while keeping the depth (`MAX_VALUE_DEPTH`), circularity,
|
||||
plain-objects-only, blocked-property, and data-only checks; un-awaited promises keep
|
||||
the await-hinting rejection in BOTH modes (deliberate - JS-parity pass-through was
|
||||
considered and skipped to preserve the nudge). The HOST boundary (final result,
|
||||
tool-call arguments, `JSON.stringify`, tool-result intake) uses the default mode and
|
||||
still serializes JSON forms (Date -> ISO, RegExp/Map/Set -> `{}`); host instances met on
|
||||
the preserving path are defensively wrapped into sandbox equivalents. Ripple: the
|
||||
`Object.*` helpers treat sandbox values as empty objects (`Object.keys(map)` -> `[]`,
|
||||
assign sources contribute nothing, hasOwn -> false - JS has no own enumerable props
|
||||
there), so interpreter internals (`.map`/`.time`/`.regex`) can never leak; the
|
||||
template-literal sandbox carve-out collapsed into `boundedData`. Object/array spread
|
||||
already preserved instances (reference copies, no checkpoint) - now tested.
|
||||
- **Console formatting**: `formatConsoleArgument` is total and deep
|
||||
(`formatConsoleValue`): numbers render via `String` (`NaN`/`Infinity`/`-Infinity`
|
||||
literally - never the JSON `null`; finite numbers match their JSON form), nested
|
||||
strings are JSON-quoted, sandbox values keep their friendly forms at ANY depth (ISO
|
||||
date, `/regex/flags`, `Map(n) [...]`, `Set(n) [...]`), opaque references become
|
||||
in-place `[CodeMode reference]` markers instead of collapsing the whole argument,
|
||||
cycles render `[Circular]` (reachable via Map/Set members, which mutation never
|
||||
checkpoints), and depth beyond `MAX_CONSOLE_DEPTH = 32` (fixed constant, not a knob)
|
||||
degrades to `...` - console can no longer fail a program. `console.table` guards with
|
||||
`containsOpaqueReference` (sandbox cells render, e.g. ISO dates) and its row/cell
|
||||
walkers treat sandbox values as scalar cells.
|
||||
- **Prose**: the instructions Syntax not-supported line dropped its `instanceof
|
||||
Error`/splice mentions (nothing else reworded); README updated (checkpoint
|
||||
preservation vs boundary serialization, error values/`instanceof`, new array/string
|
||||
methods, regex-failure behavior); `supportedSyntaxMessage` left untouched (it lists
|
||||
supported syntax, was already non-exhaustive, and stays accurate).
|
||||
- **Tests**: package suite 169 -> 209 (parity: Error/instanceof + real-JS error-name
|
||||
coverage, splice/fill/copyWithin/keys/values/entries, localeCompare/normalize/trim-alias
|
||||
describes; stdlib: checkpoint survival incl. tool-arg boundary pinning, stdlib
|
||||
`instanceof`, regex-message assertions; codemode: NaN/Infinity + nested/cyclic console
|
||||
rendering, table cells, caught-tool-failure `instanceof`); adapter suites unchanged
|
||||
(34 + 16, green); both packages `tsgo --noEmit` clean.
|
||||
|
||||
**Truncation layering - CodeMode truncation off in OpenCode** (user direction; resolves the
|
||||
section 4 outer-truncation item the OPPOSITE way from "kill the outer one"):
|
||||
- `maxOutputBytes` lost its 32,000 default and now behaves exactly like the other two
|
||||
limits: absent = no truncation. All three limits are uniformly no-default - budgets are
|
||||
host policy. `ResolvedExecutionLimits.maxOutputBytes` is `number | undefined`;
|
||||
`boundOutput` only runs when the host set the limit. Explicit values validate as before
|
||||
(safe integer >= 0).
|
||||
- OpenCode continues to pass NO limits, which now also means no CodeMode truncation.
|
||||
`execute` is a normal `Tool.define` tool, so OpenCode's native tool-output truncation
|
||||
applies with no special-casing - verified by tracing `wrap()` (`tool.ts:130-144`,
|
||||
50KB/2000-line thresholds in `truncate.ts`, full output dumped to a file under
|
||||
`tool-output/`): the `metadata.truncated` self-truncation exemption never fires for
|
||||
`execute` (its metadata never sets that key). One truncation layer, the host's - and it
|
||||
is the richer one (file dump + explore/grep hint vs an inline marker).
|
||||
- Hosts without their own output bounding set `maxOutputBytes` explicitly; README table
|
||||
and prose updated, adapter comment rewritten. Tests: codemode +1 (absent limit -> 100KB
|
||||
value + 50KB log line pass through unbounded, `truncated` undefined); the adapter test
|
||||
that relied on the old default now asserts the oversized result reaches the shared
|
||||
wrapper un-truncated. Suites: 210 + 50, tsgo clean both.
|
||||
|
||||
- `maxOutputBytes` lost its 32,000 default and now behaves exactly like the other two
|
||||
limits: absent = no truncation. All three limits are uniformly no-default - budgets are
|
||||
host policy. `ResolvedExecutionLimits.maxOutputBytes` is `number | undefined`;
|
||||
`boundOutput` only runs when the host set the limit. Explicit values validate as before
|
||||
(safe integer >= 0).
|
||||
- OpenCode continues to pass NO limits, which now also means no CodeMode truncation.
|
||||
`execute` is a normal `Tool.define` tool, so OpenCode's native tool-output truncation
|
||||
applies with no special-casing - verified by tracing `wrap()` (`tool.ts:130-144`,
|
||||
50KB/2000-line thresholds in `truncate.ts`, full output dumped to a file under
|
||||
`tool-output/`): the `metadata.truncated` self-truncation exemption never fires for
|
||||
`execute` (its metadata never sets that key). One truncation layer, the host's - and it
|
||||
is the richer one (file dump + explore/grep hint vs an inline marker).
|
||||
- Hosts without their own output bounding set `maxOutputBytes` explicitly; README table
|
||||
and prose updated, adapter comment rewritten. Tests: codemode +1 (absent limit -> 100KB
|
||||
value + 50KB log line pass through unbounded, `truncated` undefined); the adapter test
|
||||
that relied on the old default now asserts the oversized result reaches the shared
|
||||
wrapper un-truncated. Suites: 210 + 50, tsgo clean both.
|
||||
|
||||
**Docs polish** (post-API-review): stale `DiscoveryOptions` JSDoc fixed (claimed default
|
||||
4,000 and alphabetical cheapest-first - now 2,000 and round-robin, matching Fix 8/9 reality)
|
||||
@@ -837,132 +856,137 @@ regular dependency; hosts depend on it themselves because the API surface is Eff
|
||||
|
||||
**Registry promotion + permission-aware catalog** (the "promote to a proper tool service"
|
||||
restructure; fixes the section 4 permission-advertising bug):
|
||||
- **The adapter moved** `src/session/code-mode.ts` -> `src/tool/code-mode.ts` and is now a
|
||||
registry-resident tool service on the TaskTool precedent: `CodeModeTool =
|
||||
Tool.define(CODE_MODE_TOOL, ...)` whose init depends on `MCP.Service`, `Agent.Service`,
|
||||
and `Session.Service`. It is yielded in `ToolRegistry.layer`, gated into `builtin` by
|
||||
`flags.experimentalCodeMode` (like the lsp/plan experiments), and `MCP.node` joined the
|
||||
registry's `node.deps` (`MCP.node` has no ToolRegistry dependency, so no cycle). The
|
||||
session-level special-casing in `session/tools.ts` (ad-hoc `SessionCodeMode.define` +
|
||||
append) is deleted; the early return that suppresses raw per-MCP registration when the
|
||||
flag is on stays session-side, keyed on the same flag+tool-count condition.
|
||||
- **Enablement** lives in `ToolRegistry.tools()` next to the WebSearchTool check: the MCP
|
||||
tool count is consulted once (an Effect) before the synchronous filter, and code mode
|
||||
passes the predicate iff `flags.experimentalCodeMode` && count > 0.
|
||||
- **Description split on the `describeTask` precedent**: the tool's static base
|
||||
description is a two-line summary; `describeCodeMode(agent)` in `registry.tools()`
|
||||
appends the full CodeMode instructions (workflow/rules/syntax + grouped catalog,
|
||||
`catalogInstructions` in the adapter) at the same composition point as task - so
|
||||
`plugin.trigger("tool.definition")` sees the base description first.
|
||||
- **Permission-aware catalog + dispatch** (the bug fix): the visibility predicate from
|
||||
`llm/request.ts` `resolveTools` is hoisted to `Permission.visibleTools(tools, ruleset)`
|
||||
(a record filter over `Permission.disabled` - only a hard `deny` with pattern `"*"`
|
||||
hides a tool; ask-level rules stay fully visible and prompt at call time) and
|
||||
`resolveTools` now uses it, so the two paths cannot drift. `describeCodeMode` filters
|
||||
with the merged agent+session ruleset that `SessionTools.resolve` passes into the
|
||||
registry before building the catalog/search index; `execute` rebuilds the runtime per
|
||||
execution from a fresh, filtered `mcp.tools()` snapshot using the same merged ruleset
|
||||
(`Agent.get(ctx.agent)` + `Session.get(ctx.sessionID)`, matching the merge
|
||||
`SessionTools.context` wires into `ctx.ask`) - a denied tool is not dispatchable
|
||||
even if the model guesses its name and yields the normal unknown-tool diagnostic.
|
||||
Documented gap (out of scope by design): per-message `user.tools[key] === false` arrives
|
||||
at request-prep after descriptions are built and has no child-call equivalent.
|
||||
- **Preserved behavior**: cancellation race + pre-aborted-signal guard, `toSandboxResult`
|
||||
unwrap order, attachment accumulation, `CODE_MODE_TOOL` at all title sites, no execution
|
||||
limits (native truncation only), `displayInput`, per-child `ctx.ask` gating (now wired
|
||||
through `Tool.Context` exactly like every registry tool).
|
||||
- **Explicit non-goal**: memoizing the catalog builder keyed on (ToolsChanged generation,
|
||||
permission ruleset) was considered and deliberately skipped - the per-turn rebuild is
|
||||
cheap (grouping + string rendering); revisit only if profiling shows it matters.
|
||||
- **Tests**: the two adapter suites moved to `test/tool/{code-mode,code-mode-integration}
|
||||
.test.ts` (mocked `MCP.Service`/`Agent.Service`/`Session.Service` replacing the direct
|
||||
`define(...)` construction; description assertions target `catalogInstructions`, the
|
||||
registry's composition input) and gained permission coverage: deny excluded from
|
||||
catalog/search, ask-level stays visible and callable, denied tool undispatchable
|
||||
(unknown-tool diagnostic), `Permission.visibleTools` semantics. `test/tool/
|
||||
registry.test.ts` gained four registry-level tests: registered with flag+MCP tools,
|
||||
excluded without MCP tools, excluded with flag off, and deny/ask catalog filtering
|
||||
through `registry.tools()`. Suites: 43 + 16 adapter tests, 16 registry tests, all green.
|
||||
|
||||
- **The adapter moved** `src/session/code-mode.ts` -> `src/tool/code-mode.ts` and is now a
|
||||
registry-resident tool service on the TaskTool precedent: `CodeModeTool =
|
||||
Tool.define(CODE_MODE_TOOL, ...)` whose init depends on `MCP.Service`, `Agent.Service`,
|
||||
and `Session.Service`. It is yielded in `ToolRegistry.layer`, gated into `builtin` by
|
||||
`flags.experimentalCodeMode` (like the lsp/plan experiments), and `MCP.node` joined the
|
||||
registry's `node.deps` (`MCP.node` has no ToolRegistry dependency, so no cycle). The
|
||||
session-level special-casing in `session/tools.ts` (ad-hoc `SessionCodeMode.define` +
|
||||
append) is deleted; the early return that suppresses raw per-MCP registration when the
|
||||
flag is on stays session-side, keyed on the same flag+tool-count condition.
|
||||
- **Enablement** lives in `ToolRegistry.tools()` next to the WebSearchTool check: the MCP
|
||||
tool count is consulted once (an Effect) before the synchronous filter, and code mode
|
||||
passes the predicate iff `flags.experimentalCodeMode` && count > 0.
|
||||
- **Description split on the `describeTask` precedent**: the tool's static base
|
||||
description is a two-line summary; `describeCodeMode(agent)` in `registry.tools()`
|
||||
appends the full CodeMode instructions (workflow/rules/syntax + grouped catalog,
|
||||
`catalogInstructions` in the adapter) at the same composition point as task - so
|
||||
`plugin.trigger("tool.definition")` sees the base description first.
|
||||
- **Permission-aware catalog + dispatch** (the bug fix): the visibility predicate from
|
||||
`llm/request.ts` `resolveTools` is hoisted to `Permission.visibleTools(tools, ruleset)`
|
||||
(a record filter over `Permission.disabled` - only a hard `deny` with pattern `"*"`
|
||||
hides a tool; ask-level rules stay fully visible and prompt at call time) and
|
||||
`resolveTools` now uses it, so the two paths cannot drift. `describeCodeMode` filters
|
||||
with the merged agent+session ruleset that `SessionTools.resolve` passes into the
|
||||
registry before building the catalog/search index; `execute` rebuilds the runtime per
|
||||
execution from a fresh, filtered `mcp.tools()` snapshot using the same merged ruleset
|
||||
(`Agent.get(ctx.agent)` + `Session.get(ctx.sessionID)`, matching the merge
|
||||
`SessionTools.context` wires into `ctx.ask`) - a denied tool is not dispatchable
|
||||
even if the model guesses its name and yields the normal unknown-tool diagnostic.
|
||||
Documented gap (out of scope by design): per-message `user.tools[key] === false` arrives
|
||||
at request-prep after descriptions are built and has no child-call equivalent.
|
||||
- **Preserved behavior**: cancellation race + pre-aborted-signal guard, `toSandboxResult`
|
||||
unwrap order, attachment accumulation, `CODE_MODE_TOOL` at all title sites, no execution
|
||||
limits (native truncation only), `displayInput`, per-child `ctx.ask` gating (now wired
|
||||
through `Tool.Context` exactly like every registry tool).
|
||||
- **Explicit non-goal**: memoizing the catalog builder keyed on (ToolsChanged generation,
|
||||
permission ruleset) was considered and deliberately skipped - the per-turn rebuild is
|
||||
cheap (grouping + string rendering); revisit only if profiling shows it matters.
|
||||
- **Tests**: the two adapter suites moved to `test/tool/{code-mode,code-mode-integration}
|
||||
.test.ts` (mocked `MCP.Service`/`Agent.Service`/`Session.Service` replacing the direct
|
||||
`define(...)` construction; description assertions target `catalogInstructions`, the
|
||||
registry's composition input) and gained permission coverage: deny excluded from
|
||||
catalog/search, ask-level stays visible and callable, denied tool undispatchable
|
||||
(unknown-tool diagnostic), `Permission.visibleTools` semantics. `test/tool/
|
||||
registry.test.ts` gained four registry-level tests: registered with flag+MCP tools,
|
||||
excluded without MCP tools, excluded with flag off, and deny/ask catalog filtering
|
||||
through `registry.tools()`. Suites: 43 + 16 adapter tests, 16 registry tests, all green.
|
||||
|
||||
**Shared MCP invocation middle (`McpInvoke.invoke`)** (closes the section 4 "plugin hooks skip
|
||||
child calls" gap):
|
||||
- `packages/opencode/src/mcp/invoke.ts` extracts the duplicated "invoke an MCP tool"
|
||||
middle into one shared `McpInvoke.invoke(input)`: plugin `tool.execute.before` hook ->
|
||||
permission ask (`{ permission: key, patterns: ["*"], always: ["*"] }` via the caller's
|
||||
`ctx.ask`) -> dispatch through the ai-sdk tool's execute inside the `Tool.execute`
|
||||
tracing span (`tool.name`/`tool.call_id`/`session.id`/`message.id` attributes) ->
|
||||
plugin `tool.execute.after` hook. It returns the RAW result the ai-sdk execute
|
||||
resolved with; each caller keeps its own shaping edge - the legacy per-MCP loop in
|
||||
`SessionTools.resolve` applies its existing model-facing shaping/truncation, code
|
||||
mode applies `toSandboxResult`. It lives under `src/mcp/` because both callers
|
||||
already depend on MCP and the function is about invoking an MCP-backed ai-sdk tool,
|
||||
not about sessions or code mode.
|
||||
- **After-hook payload**: fired inside `McpInvoke.invoke` with the raw MCP result -
|
||||
which is exactly what the legacy loop always passed (the raw `CallToolResult`, not
|
||||
the shaped `{title, output, metadata}`), so legacy behavior is preserved bit-for-bit
|
||||
and the hook payload cannot drift between callers. No callback/edge-firing design
|
||||
was needed.
|
||||
- **Synthetic child callID**: code-mode child calls pass `${parentCallID}/${n}` as the
|
||||
hook/span callID (`parentCallID` = the `execute` call's `ctx.callID`, falling back to
|
||||
the entry key; `n` = per-execution counter starting at 1, shared across all child
|
||||
calls in one program). callID is an opaque string - nothing parses it. The ai-sdk
|
||||
`toolCallId` (`options.toolCallId`) stays each caller's existing value
|
||||
(`ctx.callID ?? entry.key` for code mode).
|
||||
- **Child-scoped hook failures**: `CodeModeTool` (which now also yields
|
||||
`Plugin.Service`) wraps the whole child call - hooks, ask, dispatch - in
|
||||
`toCatchable` (the generalization of the old `askPermission` catchCause), so a plugin
|
||||
hook failure fails ONLY that child call as a catchable in-program `toolError`; other
|
||||
calls in the same program keep running and interruption still propagates as
|
||||
interruption. Legacy semantics unchanged: a hook failure fails the tool call.
|
||||
- **Tests**: `test/tool/code-mode.test.ts` +2 (child calls fire before/after with the
|
||||
MCP key and `parent/1`, `parent/2` ids, after hook carries the raw MCP result; a
|
||||
failing before hook is caught in-program, gates dispatch, and leaves the outer
|
||||
execute ok) - both code-mode harnesses gained a `Plugin.Service` mock (pass-through
|
||||
trigger by default, overridable). New `test/session/tools.test.ts` (3 tests) pins
|
||||
`SessionTools.resolve` at the real-registry seam (LayerNode.compile, fake MCP layer):
|
||||
flag on + MCP tools -> `execute` present, raw MCP keys suppressed; flag off -> raw
|
||||
keys present, `execute` absent; and the legacy raw-MCP execute fires before/after
|
||||
hooks keyed by the ai-sdk toolCallId with the raw result payload. Suites: adapter
|
||||
45 + 16, session/tool/permission all green; this package untouched (211 pass).
|
||||
|
||||
- `packages/opencode/src/mcp/invoke.ts` extracts the duplicated "invoke an MCP tool"
|
||||
middle into one shared `McpInvoke.invoke(input)`: plugin `tool.execute.before` hook ->
|
||||
permission ask (`{ permission: key, patterns: ["*"], always: ["*"] }` via the caller's
|
||||
`ctx.ask`) -> dispatch through the ai-sdk tool's execute inside the `Tool.execute`
|
||||
tracing span (`tool.name`/`tool.call_id`/`session.id`/`message.id` attributes) ->
|
||||
plugin `tool.execute.after` hook. It returns the RAW result the ai-sdk execute
|
||||
resolved with; each caller keeps its own shaping edge - the legacy per-MCP loop in
|
||||
`SessionTools.resolve` applies its existing model-facing shaping/truncation, code
|
||||
mode applies `toSandboxResult`. It lives under `src/mcp/` because both callers
|
||||
already depend on MCP and the function is about invoking an MCP-backed ai-sdk tool,
|
||||
not about sessions or code mode.
|
||||
- **After-hook payload**: fired inside `McpInvoke.invoke` with the raw MCP result -
|
||||
which is exactly what the legacy loop always passed (the raw `CallToolResult`, not
|
||||
the shaped `{title, output, metadata}`), so legacy behavior is preserved bit-for-bit
|
||||
and the hook payload cannot drift between callers. No callback/edge-firing design
|
||||
was needed.
|
||||
- **Synthetic child callID**: code-mode child calls pass `${parentCallID}/${n}` as the
|
||||
hook/span callID (`parentCallID` = the `execute` call's `ctx.callID`, falling back to
|
||||
the entry key; `n` = per-execution counter starting at 1, shared across all child
|
||||
calls in one program). callID is an opaque string - nothing parses it. The ai-sdk
|
||||
`toolCallId` (`options.toolCallId`) stays each caller's existing value
|
||||
(`ctx.callID ?? entry.key` for code mode).
|
||||
- **Child-scoped hook failures**: `CodeModeTool` (which now also yields
|
||||
`Plugin.Service`) wraps the whole child call - hooks, ask, dispatch - in
|
||||
`toCatchable` (the generalization of the old `askPermission` catchCause), so a plugin
|
||||
hook failure fails ONLY that child call as a catchable in-program `toolError`; other
|
||||
calls in the same program keep running and interruption still propagates as
|
||||
interruption. Legacy semantics unchanged: a hook failure fails the tool call.
|
||||
- **Tests**: `test/tool/code-mode.test.ts` +2 (child calls fire before/after with the
|
||||
MCP key and `parent/1`, `parent/2` ids, after hook carries the raw MCP result; a
|
||||
failing before hook is caught in-program, gates dispatch, and leaves the outer
|
||||
execute ok) - both code-mode harnesses gained a `Plugin.Service` mock (pass-through
|
||||
trigger by default, overridable). New `test/session/tools.test.ts` (3 tests) pins
|
||||
`SessionTools.resolve` at the real-registry seam (LayerNode.compile, fake MCP layer):
|
||||
flag on + MCP tools -> `execute` present, raw MCP keys suppressed; flag off -> raw
|
||||
keys present, `execute` absent; and the legacy raw-MCP execute fires before/after
|
||||
hooks keyed by the ai-sdk toolCallId with the raw result payload. Suites: adapter
|
||||
45 + 16, session/tool/permission all green; this package untouched (211 pass).
|
||||
|
||||
**Signature rendering + compound-assignment parity fixes** (externally reported, both
|
||||
verified real with failing tests before fixing):
|
||||
- **Non-identifier property names in rendered signatures** (`src/tool.ts`): `renderSchema`
|
||||
emitted raw property names, so schema properties like `foo-bar`/`@type`/`x.y`/`123`
|
||||
rendered invalid TypeScript (`{ foo-bar?: string }`). Fixed with a `renderKey` helper -
|
||||
bare identifiers stay bare, everything else is `JSON.stringify`-quoted - applied in the
|
||||
single `field` closure both the compact and pretty renderings share. The
|
||||
`identifierSegment` regex now lives in `tool.ts` (exported) and `tool-runtime.ts`'s
|
||||
bracket-notation `toolExpression` imports it: one source of truth for "is this a bare
|
||||
identifier" across object keys and tool paths. Tests: `signature.test.ts` +4 (compact,
|
||||
pretty with JSDoc on a quoted key, JSON Schema input+output, Effect Schema struct).
|
||||
- **Numeric schema unions keep their real alternatives** (`src/tool.ts`): the old
|
||||
`anyOf`/`oneOf` renderer collapsed any union containing `{ type: "number" }` to just
|
||||
`number`, dropping real JSON Schema alternatives (`string | number`, `number | null`,
|
||||
etc.). The collapse is now restricted to Effect's number-schema artifact
|
||||
(`number | "NaN" | "Infinity" | "-Infinity"`, emitted as single-value string enums),
|
||||
while raw JSON Schema unions render every branch. Tests: `signature.test.ts` +3.
|
||||
- **Compound assignment now matches binary-operator semantics** (`src/codemode.ts`):
|
||||
`applyCompoundAssignment` did raw JS ops on interpreter wrapper objects, so `x += y`
|
||||
diverged from `x = x + y` (sandbox Date `d += 1` produced `"[object Object]1"`;
|
||||
`d -= 400` gave `NaN` instead of epoch arithmetic). The operator table + coercion moved
|
||||
verbatim out of `evaluateBinaryExpression` into a shared `applyBinaryOperator`;
|
||||
compound assignment validates against a `compoundOperators` set (`+=` ... `>>>=`) and
|
||||
dispatches through it (`operator.slice(0, -1)`). Logical assignments (`&&=`/`||=`/`??=`)
|
||||
keep their separate short-circuit path (`evaluateLogicalAssignment`), and both
|
||||
assignment call sites still wrap results in `boundedData`. Deliberate side effect:
|
||||
compound assignment now rejects opaque references, consistent with binary operators.
|
||||
Tests: `parity.test.ts` +5 (Date `+=` concat parity, Date `-=`/`/=` epoch parity,
|
||||
string `+=` object/array, member-target compound, 13-case operator sweep vs real JS).
|
||||
Package suite: 220 pass.
|
||||
|
||||
- **Non-identifier property names in rendered signatures** (`src/tool.ts`): `renderSchema`
|
||||
emitted raw property names, so schema properties like `foo-bar`/`@type`/`x.y`/`123`
|
||||
rendered invalid TypeScript (`{ foo-bar?: string }`). Fixed with a `renderKey` helper -
|
||||
bare identifiers stay bare, everything else is `JSON.stringify`-quoted - applied in the
|
||||
single `field` closure both the compact and pretty renderings share. The
|
||||
`identifierSegment` regex now lives in `tool.ts` (exported) and `tool-runtime.ts`'s
|
||||
bracket-notation `toolExpression` imports it: one source of truth for "is this a bare
|
||||
identifier" across object keys and tool paths. Tests: `signature.test.ts` +4 (compact,
|
||||
pretty with JSDoc on a quoted key, JSON Schema input+output, Effect Schema struct).
|
||||
- **Numeric schema unions keep their real alternatives** (`src/tool.ts`): the old
|
||||
`anyOf`/`oneOf` renderer collapsed any union containing `{ type: "number" }` to just
|
||||
`number`, dropping real JSON Schema alternatives (`string | number`, `number | null`,
|
||||
etc.). The collapse is now restricted to Effect's number-schema artifact
|
||||
(`number | "NaN" | "Infinity" | "-Infinity"`, emitted as single-value string enums),
|
||||
while raw JSON Schema unions render every branch. Tests: `signature.test.ts` +3.
|
||||
- **Compound assignment now matches binary-operator semantics** (`src/codemode.ts`):
|
||||
`applyCompoundAssignment` did raw JS ops on interpreter wrapper objects, so `x += y`
|
||||
diverged from `x = x + y` (sandbox Date `d += 1` produced `"[object Object]1"`;
|
||||
`d -= 400` gave `NaN` instead of epoch arithmetic). The operator table + coercion moved
|
||||
verbatim out of `evaluateBinaryExpression` into a shared `applyBinaryOperator`;
|
||||
compound assignment validates against a `compoundOperators` set (`+=` ... `>>>=`) and
|
||||
dispatches through it (`operator.slice(0, -1)`). Logical assignments (`&&=`/`||=`/`??=`)
|
||||
keep their separate short-circuit path (`evaluateLogicalAssignment`), and both
|
||||
assignment call sites still wrap results in `boundedData`. Deliberate side effect:
|
||||
compound assignment now rejects opaque references, consistent with binary operators.
|
||||
Tests: `parity.test.ts` +5 (Date `+=` concat parity, Date `-=`/`/=` epoch parity,
|
||||
string `+=` object/array, member-target compound, 13-case operator sweep vs real JS).
|
||||
Package suite: 220 pass.
|
||||
|
||||
---
|
||||
|
||||
## 4. Remaining work (detailed TODO)
|
||||
|
||||
### Next DSL-expansion pass (done - see the DSL-expansion pass entry in section 3)
|
||||
|
||||
Batch these together - per user direction: important, but deliberately deferred to one
|
||||
focused interpreter-surface pass rather than picked off piecemeal.
|
||||
|
||||
- [x] Medium-tier JS parity items deferred from the original audit: caught errors are plain
|
||||
`{ name, message }` objects, not `instanceof Error` (and `Error` isn't a value -
|
||||
`x instanceof Error` is unsupported syntax); `splice` (still a
|
||||
@@ -981,6 +1005,7 @@ focused interpreter-surface pass rather than picked off piecemeal.
|
||||
(`console.log({ m: map })`) - could deep-format instead.
|
||||
|
||||
### Next iteration: text-result handling (deliberate follow-up, user-directed)
|
||||
|
||||
- [ ] Revisit how MCP text results reach the program. Today: `structuredContent` when the
|
||||
server sends it, else joined text as a plain string (the program JSON.parses it,
|
||||
guided by a workflow step). Considered and deferred: (a) conservative boundary
|
||||
@@ -992,6 +1017,7 @@ focused interpreter-surface pass rather than picked off piecemeal.
|
||||
revisit once real usage shows which failure modes matter.
|
||||
|
||||
### Next iteration: stdlib surface (prioritized)
|
||||
|
||||
Current instructions say "usual Array/String/Object/Math/JSON methods," but the interpreter is
|
||||
intentionally a subset. Keep CodeMode focused on orchestration and data shaping, not a full host
|
||||
runtime, but close the high-friction gaps models are likely to reach for.
|
||||
@@ -1028,7 +1054,9 @@ Explicit non-goals for now: `structuredClone`, `WeakMap`/`WeakSet`, and timers
|
||||
orchestration use case.
|
||||
|
||||
### Wiring-review findings (subagent code review of the OpenCode integration, triaged)
|
||||
|
||||
Pre-PR fixes (user-approved cut):
|
||||
|
||||
- [x] **Cancellation does not interrupt the interpreter** - the no-limits rationale claimed
|
||||
"user cancel interrupts the execution fiber," but `tools.ts` runs tools via
|
||||
`run.promise` -> `Effect.runPromise` (`effect/bridge.ts:64-66`) with NO abort wiring;
|
||||
@@ -1073,6 +1101,7 @@ Pre-PR fixes (user-approved cut):
|
||||
`title: "execute"` sites in `code-mode.ts` now reference `CODE_MODE_TOOL`.
|
||||
|
||||
Post-MVP (logged, not blocking an experimental flag):
|
||||
|
||||
- [x] **Plugin `tool.execute.before/after` hooks skip child calls** - legacy MCP
|
||||
registration fires them per tool (`tools.ts:419-441`); under code mode only the
|
||||
outer `execute` fires them, so auditing/intercepting plugins silently lose MCP
|
||||
@@ -1105,6 +1134,7 @@ Post-MVP (logged, not blocking an experimental flag):
|
||||
names that are no longer directly callable under code mode.
|
||||
|
||||
### Backlog / loose ends (non-blocking, any order)
|
||||
|
||||
- [ ] `evaluateUpdateExpression` (`++`/`--`) still uses raw `Number(current)`, so `d++` on a
|
||||
sandbox Date yields `NaN` where `d += 1` now uses epoch semantics (and real JS `d++`
|
||||
would give epoch+0 numeric). Pre-existing, out of scope of the compound-assignment
|
||||
@@ -1141,7 +1171,7 @@ Post-MVP (logged, not blocking an experimental flag):
|
||||
## 5. Context and gotchas for whoever picks this up
|
||||
|
||||
- **Motivating failure (why forgiving semantics + prompting matter):** in a real transcript,
|
||||
the model wrote `me.result?.login ?? me.result` where the tool result was a JSON *string* -
|
||||
the model wrote `me.result?.login ?? me.result` where the tool result was a JSON _string_ -
|
||||
the old strict interpreter threw (`String property 'login' is not available`); then the
|
||||
model returned a raw 105KB payload, which native truncation dumped to a file, costing a
|
||||
subagent round-trip to extract one number. Interpreter forgiveness stops the crashes;
|
||||
|
||||
+889
-348
@@ -118,9 +118,16 @@ export type CodeModeOptions<Tools extends Record<string, unknown> = {}> = Omit<E
|
||||
export const ExecuteInputSchema = Schema.Struct({ code: Schema.String })
|
||||
|
||||
const DiagnosticKindSchema = Schema.Literals([
|
||||
"ParseError", "UnsupportedSyntax", "UnknownTool", "InvalidToolInput", "InvalidToolOutput", "InvalidDataValue",
|
||||
"ToolCallLimitExceeded", "TimeoutExceeded",
|
||||
"ToolFailure", "ExecutionFailure",
|
||||
"ParseError",
|
||||
"UnsupportedSyntax",
|
||||
"UnknownTool",
|
||||
"InvalidToolInput",
|
||||
"InvalidToolOutput",
|
||||
"InvalidDataValue",
|
||||
"ToolCallLimitExceeded",
|
||||
"TimeoutExceeded",
|
||||
"ToolFailure",
|
||||
"ExecutionFailure",
|
||||
])
|
||||
|
||||
/** Structured success or diagnostic result schema returned by CodeMode execution. */
|
||||
@@ -245,7 +252,10 @@ class GlobalNamespace {
|
||||
}
|
||||
|
||||
class GlobalMethodReference {
|
||||
constructor(readonly namespace: GlobalNamespaceName | "Number" | "String", readonly name: string) {}
|
||||
constructor(
|
||||
readonly namespace: GlobalNamespaceName | "Number" | "String",
|
||||
readonly name: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
class CoercionFunction {
|
||||
@@ -290,10 +300,41 @@ export type DiagnosticKind =
|
||||
| "ExecutionFailure"
|
||||
|
||||
const arrayMethods = new Set([
|
||||
"map", "filter", "find", "findIndex", "findLast", "findLastIndex", "some", "every", "includes", "join",
|
||||
"reduce", "reduceRight", "flatMap", "forEach", "sort", "toSorted", "slice", "concat", "indexOf", "lastIndexOf",
|
||||
"at", "flat", "reverse", "toReversed", "with", "push", "pop", "shift", "unshift",
|
||||
"splice", "fill", "copyWithin", "keys", "values", "entries",
|
||||
"map",
|
||||
"filter",
|
||||
"find",
|
||||
"findIndex",
|
||||
"findLast",
|
||||
"findLastIndex",
|
||||
"some",
|
||||
"every",
|
||||
"includes",
|
||||
"join",
|
||||
"reduce",
|
||||
"reduceRight",
|
||||
"flatMap",
|
||||
"forEach",
|
||||
"sort",
|
||||
"toSorted",
|
||||
"slice",
|
||||
"concat",
|
||||
"indexOf",
|
||||
"lastIndexOf",
|
||||
"at",
|
||||
"flat",
|
||||
"reverse",
|
||||
"toReversed",
|
||||
"with",
|
||||
"push",
|
||||
"pop",
|
||||
"shift",
|
||||
"unshift",
|
||||
"splice",
|
||||
"fill",
|
||||
"copyWithin",
|
||||
"keys",
|
||||
"values",
|
||||
"entries",
|
||||
])
|
||||
|
||||
const mathConstants = new Set(["PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2"])
|
||||
@@ -301,10 +342,38 @@ const mathConstants = new Set(["PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQR
|
||||
const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString"])
|
||||
|
||||
const stringMethods = new Set([
|
||||
"toLowerCase", "toUpperCase", "trim", "trimStart", "trimEnd", "trimLeft", "trimRight", "split", "slice",
|
||||
"substring", "substr", "includes", "startsWith", "endsWith", "indexOf", "lastIndexOf", "replace", "replaceAll",
|
||||
"repeat", "padStart", "padEnd", "charAt", "charCodeAt", "codePointAt", "at", "concat", "toString",
|
||||
"match", "matchAll", "search", "localeCompare", "normalize",
|
||||
"toLowerCase",
|
||||
"toUpperCase",
|
||||
"trim",
|
||||
"trimStart",
|
||||
"trimEnd",
|
||||
"trimLeft",
|
||||
"trimRight",
|
||||
"split",
|
||||
"slice",
|
||||
"substring",
|
||||
"substr",
|
||||
"includes",
|
||||
"startsWith",
|
||||
"endsWith",
|
||||
"indexOf",
|
||||
"lastIndexOf",
|
||||
"replace",
|
||||
"replaceAll",
|
||||
"repeat",
|
||||
"padStart",
|
||||
"padEnd",
|
||||
"charAt",
|
||||
"charCodeAt",
|
||||
"codePointAt",
|
||||
"at",
|
||||
"concat",
|
||||
"toString",
|
||||
"match",
|
||||
"matchAll",
|
||||
"search",
|
||||
"localeCompare",
|
||||
"normalize",
|
||||
])
|
||||
|
||||
const numberConstants = new Set(["MAX_SAFE_INTEGER", "MIN_SAFE_INTEGER", "MAX_VALUE", "MIN_VALUE", "EPSILON"])
|
||||
@@ -317,21 +386,57 @@ const consoleMethods = new Set(["log", "info", "debug", "warn", "error", "dir",
|
||||
|
||||
const promiseStatics = new Set<PromiseMethodName>(["all", "allSettled", "race", "resolve", "reject"])
|
||||
|
||||
const errorConstructors = new Set(["Error", "TypeError", "RangeError", "SyntaxError", "ReferenceError", "EvalError", "URIError"])
|
||||
const errorConstructors = new Set([
|
||||
"Error",
|
||||
"TypeError",
|
||||
"RangeError",
|
||||
"SyntaxError",
|
||||
"ReferenceError",
|
||||
"EvalError",
|
||||
"URIError",
|
||||
])
|
||||
|
||||
const valueConstructors = new Set(["Date", "RegExp", "Map", "Set"])
|
||||
|
||||
const dateMethods = new Set([
|
||||
"getTime", "valueOf", "toISOString", "toJSON", "toString",
|
||||
"getFullYear", "getMonth", "getDate", "getDay", "getHours", "getMinutes", "getSeconds", "getMilliseconds",
|
||||
"getUTCFullYear", "getUTCMonth", "getUTCDate", "getUTCDay", "getUTCHours", "getUTCMinutes", "getUTCSeconds", "getUTCMilliseconds",
|
||||
"getTime",
|
||||
"valueOf",
|
||||
"toISOString",
|
||||
"toJSON",
|
||||
"toString",
|
||||
"getFullYear",
|
||||
"getMonth",
|
||||
"getDate",
|
||||
"getDay",
|
||||
"getHours",
|
||||
"getMinutes",
|
||||
"getSeconds",
|
||||
"getMilliseconds",
|
||||
"getUTCFullYear",
|
||||
"getUTCMonth",
|
||||
"getUTCDate",
|
||||
"getUTCDay",
|
||||
"getUTCHours",
|
||||
"getUTCMinutes",
|
||||
"getUTCSeconds",
|
||||
"getUTCMilliseconds",
|
||||
"getTimezoneOffset",
|
||||
])
|
||||
const dateStatics = new Set(["now", "parse", "UTC"])
|
||||
|
||||
const regexpMethods = new Set(["test", "exec", "toString"])
|
||||
// Read-only host regex fields surfaced as plain values.
|
||||
const regexpProperties = new Set(["source", "flags", "lastIndex", "global", "ignoreCase", "multiline", "sticky", "unicode", "dotAll"])
|
||||
const regexpProperties = new Set([
|
||||
"source",
|
||||
"flags",
|
||||
"lastIndex",
|
||||
"global",
|
||||
"ignoreCase",
|
||||
"multiline",
|
||||
"sticky",
|
||||
"unicode",
|
||||
"dotAll",
|
||||
])
|
||||
|
||||
const mapMethods = new Set(["get", "set", "has", "delete", "clear", "forEach", "keys", "values", "entries"])
|
||||
const setMethods = new Set(["add", "has", "delete", "clear", "forEach", "keys", "values", "entries"])
|
||||
@@ -342,7 +447,12 @@ const supportedSyntaxMessage =
|
||||
"Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (promise chaining with .then/.catch is not supported - use await with try/catch)."
|
||||
|
||||
const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError =>
|
||||
new InterpreterRuntimeError(`Syntax '${kind}' is not supported in CodeMode. ${supportedSyntaxMessage}`, node, "UnsupportedSyntax", [supportedSyntaxMessage])
|
||||
new InterpreterRuntimeError(
|
||||
`Syntax '${kind}' is not supported in CodeMode. ${supportedSyntaxMessage}`,
|
||||
node,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
|
||||
/** How many eagerly forked tool calls may run at once. Fixed; not a configurable knob. */
|
||||
const TOOL_CALL_CONCURRENCY = 8
|
||||
@@ -350,7 +460,11 @@ const TOOL_CALL_CONCURRENCY = 8
|
||||
/** Console formatting recursion ceiling; deeper values render as "...". Fixed; not a knob. */
|
||||
const MAX_CONSOLE_DEPTH = 32
|
||||
|
||||
const validateLimit = <Value extends number | undefined>(name: keyof ExecutionLimits, value: Value, minimum: number): Value => {
|
||||
const validateLimit = <Value extends number | undefined>(
|
||||
name: keyof ExecutionLimits,
|
||||
value: Value,
|
||||
minimum: number,
|
||||
): Value => {
|
||||
if (value !== undefined && (!Number.isSafeInteger(value) || value < minimum)) {
|
||||
throw new RangeError(`${name} must be a safe integer greater than or equal to ${minimum}.`)
|
||||
}
|
||||
@@ -377,7 +491,12 @@ class InterpreterRuntimeError extends Error {
|
||||
*/
|
||||
errorName: string = "Error"
|
||||
|
||||
constructor(message: string, node?: AstNode, readonly kind: DiagnosticKind = "ExecutionFailure", readonly suggestions?: ReadonlyArray<string>) {
|
||||
constructor(
|
||||
message: string,
|
||||
node?: AstNode,
|
||||
readonly kind: DiagnosticKind = "ExecutionFailure",
|
||||
readonly suggestions?: ReadonlyArray<string>,
|
||||
) {
|
||||
super(message)
|
||||
this.name = "InterpreterRuntimeError"
|
||||
|
||||
@@ -392,8 +511,7 @@ class InterpreterRuntimeError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null
|
||||
|
||||
const asNode = (value: unknown, context: string): AstNode => {
|
||||
if (!isRecord(value) || typeof value.type !== "string") {
|
||||
@@ -527,7 +645,11 @@ const normalizeError = (error: unknown): Diagnostic => {
|
||||
message = "a non-data value"
|
||||
} else if (typeof value === "string") {
|
||||
message = value
|
||||
} else if (value !== null && typeof value === "object" && typeof (value as { message?: unknown }).message === "string") {
|
||||
} else if (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as { message?: unknown }).message === "string"
|
||||
) {
|
||||
message = (value as { message: string }).message
|
||||
} else {
|
||||
try {
|
||||
@@ -572,10 +694,17 @@ const caughtErrorValue = (thrown: unknown): unknown => {
|
||||
const boundedData = (value: unknown, label: string): unknown => copyIn(value, label, true)
|
||||
|
||||
const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof CodeModeFunction || value instanceof ToolReference || value instanceof IntrinsicReference ||
|
||||
value instanceof GlobalNamespace || value instanceof GlobalMethodReference || value instanceof PromiseNamespace ||
|
||||
value instanceof PromiseMethodReference || value instanceof SandboxPromise || value instanceof CoercionFunction ||
|
||||
value instanceof ErrorConstructorReference || isSandboxValue(value)
|
||||
value instanceof CodeModeFunction ||
|
||||
value instanceof ToolReference ||
|
||||
value instanceof IntrinsicReference ||
|
||||
value instanceof GlobalNamespace ||
|
||||
value instanceof GlobalMethodReference ||
|
||||
value instanceof PromiseNamespace ||
|
||||
value instanceof PromiseMethodReference ||
|
||||
value instanceof SandboxPromise ||
|
||||
value instanceof CoercionFunction ||
|
||||
value instanceof ErrorConstructorReference ||
|
||||
isSandboxValue(value)
|
||||
|
||||
const containsRuntimeReference = (value: unknown, seen = new Set<object>()): boolean => {
|
||||
if (isRuntimeReference(value)) return true
|
||||
@@ -610,10 +739,15 @@ const containsOpaqueReference = (value: unknown, seen = new Set<object>()): bool
|
||||
// like a real JS promise.
|
||||
const typeofValue = (value: unknown): string => {
|
||||
if (
|
||||
value instanceof CodeModeFunction || value instanceof CoercionFunction || value instanceof IntrinsicReference ||
|
||||
value instanceof GlobalMethodReference || value instanceof PromiseMethodReference || value instanceof PromiseNamespace ||
|
||||
value instanceof CodeModeFunction ||
|
||||
value instanceof CoercionFunction ||
|
||||
value instanceof IntrinsicReference ||
|
||||
value instanceof GlobalMethodReference ||
|
||||
value instanceof PromiseMethodReference ||
|
||||
value instanceof PromiseNamespace ||
|
||||
value instanceof ErrorConstructorReference
|
||||
) return "function"
|
||||
)
|
||||
return "function"
|
||||
if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object"
|
||||
if (value instanceof GlobalNamespace) {
|
||||
return value.name === "Math" || value.name === "JSON" || value.name === "console" ? "object" : "function"
|
||||
@@ -632,12 +766,18 @@ const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean =>
|
||||
}
|
||||
if (rhs instanceof GlobalNamespace) {
|
||||
switch (rhs.name) {
|
||||
case "Date": return lhs instanceof SandboxDate
|
||||
case "RegExp": return lhs instanceof SandboxRegExp
|
||||
case "Map": return lhs instanceof SandboxMap
|
||||
case "Set": return lhs instanceof SandboxSet
|
||||
case "Array": return Array.isArray(lhs)
|
||||
case "Object": return lhs !== null && (typeof lhs === "object" || typeofValue(lhs) === "function")
|
||||
case "Date":
|
||||
return lhs instanceof SandboxDate
|
||||
case "RegExp":
|
||||
return lhs instanceof SandboxRegExp
|
||||
case "Map":
|
||||
return lhs instanceof SandboxMap
|
||||
case "Set":
|
||||
return lhs instanceof SandboxSet
|
||||
case "Array":
|
||||
return Array.isArray(lhs)
|
||||
case "Object":
|
||||
return lhs !== null && (typeof lhs === "object" || typeofValue(lhs) === "function")
|
||||
}
|
||||
}
|
||||
if (rhs instanceof PromiseNamespace) return lhs instanceof SandboxPromise
|
||||
@@ -703,12 +843,14 @@ const matchToValue = (match: RegExpMatchArray): Array<unknown> => {
|
||||
const invokeStringMethod = (value: string, name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
const str = (index: number): string => {
|
||||
const arg = args[index]
|
||||
if (typeof arg !== "string") throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node)
|
||||
if (typeof arg !== "string")
|
||||
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node)
|
||||
return arg
|
||||
}
|
||||
const num = (index: number): number => {
|
||||
const arg = args[index]
|
||||
if (typeof arg !== "number") throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node)
|
||||
if (typeof arg !== "number")
|
||||
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node)
|
||||
return arg
|
||||
}
|
||||
const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index))
|
||||
@@ -716,15 +858,29 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
|
||||
let result: unknown
|
||||
switch (name) {
|
||||
case "toLowerCase": result = value.toLowerCase(); break
|
||||
case "toUpperCase": result = value.toUpperCase(); break
|
||||
case "trim": result = value.trim(); break
|
||||
case "toLowerCase":
|
||||
result = value.toLowerCase()
|
||||
break
|
||||
case "toUpperCase":
|
||||
result = value.toUpperCase()
|
||||
break
|
||||
case "trim":
|
||||
result = value.trim()
|
||||
break
|
||||
// trimLeft/trimRight are the legacy aliases of trimStart/trimEnd, kept because models write them.
|
||||
case "trimStart": case "trimLeft": result = value.trimStart(); break
|
||||
case "trimEnd": case "trimRight": result = value.trimEnd(); break
|
||||
case "trimStart":
|
||||
case "trimLeft":
|
||||
result = value.trimStart()
|
||||
break
|
||||
case "trimEnd":
|
||||
case "trimRight":
|
||||
result = value.trimEnd()
|
||||
break
|
||||
// Locale/options arguments are ignored: comparison runs with the host default locale, and
|
||||
// the common use is a sort comparator where any consistent order works.
|
||||
case "localeCompare": result = value.localeCompare(str(0)); break
|
||||
case "localeCompare":
|
||||
result = value.localeCompare(str(0))
|
||||
break
|
||||
case "normalize": {
|
||||
const form = optStr(0)
|
||||
try {
|
||||
@@ -750,16 +906,33 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
result = value.split(str(0), requestedLimit === undefined ? undefined : requestedLimit >>> 0)
|
||||
break
|
||||
}
|
||||
case "slice": result = value.slice(optNum(0), optNum(1)); break
|
||||
case "includes": result = value.includes(str(0), optNum(1)); break
|
||||
case "startsWith": result = value.startsWith(str(0), optNum(1)); break
|
||||
case "endsWith": result = value.endsWith(str(0), optNum(1)); break
|
||||
case "indexOf": result = value.indexOf(str(0), optNum(1)); break
|
||||
case "lastIndexOf": result = value.lastIndexOf(str(0), optNum(1)); break
|
||||
case "slice":
|
||||
result = value.slice(optNum(0), optNum(1))
|
||||
break
|
||||
case "includes":
|
||||
result = value.includes(str(0), optNum(1))
|
||||
break
|
||||
case "startsWith":
|
||||
result = value.startsWith(str(0), optNum(1))
|
||||
break
|
||||
case "endsWith":
|
||||
result = value.endsWith(str(0), optNum(1))
|
||||
break
|
||||
case "indexOf":
|
||||
result = value.indexOf(str(0), optNum(1))
|
||||
break
|
||||
case "lastIndexOf":
|
||||
result = value.lastIndexOf(str(0), optNum(1))
|
||||
break
|
||||
case "replace":
|
||||
case "replaceAll": {
|
||||
if (args[0] instanceof CodeModeFunction || args[1] instanceof CodeModeFunction) {
|
||||
throw new InterpreterRuntimeError(`String.${name} does not support function replacers in CodeMode; use match/matchAll and rebuild the string instead.`, node, "UnsupportedSyntax", [supportedSyntaxMessage])
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.${name} does not support function replacers in CodeMode; use match/matchAll and rebuild the string instead.`,
|
||||
node,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
if (args[0] instanceof SandboxRegExp) {
|
||||
const pattern = (args[0] as SandboxRegExp).regex
|
||||
@@ -807,26 +980,46 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
}
|
||||
case "repeat": {
|
||||
const count = num(0)
|
||||
if (!Number.isFinite(count) || count < 0) throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node)
|
||||
if (!Number.isFinite(count) || count < 0)
|
||||
throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node)
|
||||
result = value.repeat(count)
|
||||
break
|
||||
}
|
||||
case "padStart": result = value.padStart(num(0), optStr(1)); break
|
||||
case "padEnd": result = value.padEnd(num(0), optStr(1)); break
|
||||
case "charAt": result = value.charAt(optNum(0) ?? 0); break
|
||||
case "at": result = value.at(optNum(0) ?? 0); break
|
||||
case "substring": result = value.substring(optNum(0) ?? 0, optNum(1)); break
|
||||
case "substr": result = value.substr(optNum(0) ?? 0, optNum(1)); break
|
||||
case "padStart":
|
||||
result = value.padStart(num(0), optStr(1))
|
||||
break
|
||||
case "padEnd":
|
||||
result = value.padEnd(num(0), optStr(1))
|
||||
break
|
||||
case "charAt":
|
||||
result = value.charAt(optNum(0) ?? 0)
|
||||
break
|
||||
case "at":
|
||||
result = value.at(optNum(0) ?? 0)
|
||||
break
|
||||
case "substring":
|
||||
result = value.substring(optNum(0) ?? 0, optNum(1))
|
||||
break
|
||||
case "substr":
|
||||
result = value.substr(optNum(0) ?? 0, optNum(1))
|
||||
break
|
||||
// JS charCodeAt returns NaN out of range; NaN flows as an ordinary in-sandbox value
|
||||
// (normalized to null only at the data boundary - see copyOut), so return it as-is.
|
||||
case "charCodeAt": result = value.charCodeAt(optNum(0) ?? 0); break
|
||||
case "codePointAt": result = value.codePointAt(optNum(0) ?? 0); break
|
||||
case "toString": result = value; break
|
||||
case "charCodeAt":
|
||||
result = value.charCodeAt(optNum(0) ?? 0)
|
||||
break
|
||||
case "codePointAt":
|
||||
result = value.codePointAt(optNum(0) ?? 0)
|
||||
break
|
||||
case "toString":
|
||||
result = value
|
||||
break
|
||||
case "concat": {
|
||||
result = value.concat(...args.map((_, index) => str(index)))
|
||||
break
|
||||
}
|
||||
default: throw new InterpreterRuntimeError(`String method '${name}' is not available in CodeMode.`, node)
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`String method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
return boundedData(result, `String.${name} result`)
|
||||
}
|
||||
@@ -840,8 +1033,12 @@ const invokeNumberMethod = (value: number, name: string, args: Array<unknown>, n
|
||||
}
|
||||
let result: unknown
|
||||
switch (name) {
|
||||
case "toFixed": result = value.toFixed(optNum(0)); break
|
||||
case "toExponential": result = value.toExponential(optNum(0)); break
|
||||
case "toFixed":
|
||||
result = value.toFixed(optNum(0))
|
||||
break
|
||||
case "toExponential":
|
||||
result = value.toExponential(optNum(0))
|
||||
break
|
||||
case "toPrecision": {
|
||||
const digits = optNum(0)
|
||||
result = digits === undefined ? value.toString() : value.toPrecision(digits)
|
||||
@@ -855,7 +1052,8 @@ const invokeNumberMethod = (value: number, name: string, args: Array<unknown>, n
|
||||
result = value.toString(radix)
|
||||
break
|
||||
}
|
||||
default: throw new InterpreterRuntimeError(`Number method '${name}' is not available in CodeMode.`, node)
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Number method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
return boundedData(result, `Number.${name} result`)
|
||||
}
|
||||
@@ -866,7 +1064,8 @@ const coerceToString = (value: unknown): string => {
|
||||
if (value === undefined) return "undefined"
|
||||
// Sandbox values stringify deterministically: Date as ISO (not the host's locale/timezone
|
||||
// toString), RegExp as its literal form, Map/Set with their JS Object.prototype tags.
|
||||
if (value instanceof SandboxDate) return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date"
|
||||
if (value instanceof SandboxDate)
|
||||
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date"
|
||||
if (value instanceof SandboxRegExp) return `/${value.regex.source}/${value.regex.flags}`
|
||||
if (value instanceof SandboxMap) return "[object Map]"
|
||||
if (value instanceof SandboxSet) return "[object Set]"
|
||||
@@ -903,7 +1102,8 @@ const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node: AstNo
|
||||
if (ref.name === "Boolean") return Boolean(value)
|
||||
if (ref.name === "parseInt") {
|
||||
const radix = args[1]
|
||||
if (radix !== undefined && typeof radix !== "number") throw new InterpreterRuntimeError("parseInt expects a numeric radix.", node)
|
||||
if (radix !== undefined && typeof radix !== "number")
|
||||
throw new InterpreterRuntimeError("parseInt expects a numeric radix.", node)
|
||||
return parseInt(coerceToString(value), radix)
|
||||
}
|
||||
if (ref.name === "parseFloat") return parseFloat(coerceToString(value))
|
||||
@@ -938,9 +1138,12 @@ const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode):
|
||||
}
|
||||
return Object.keys(value)
|
||||
}
|
||||
case "values": return Object.values(requireObject())
|
||||
case "entries": return Object.entries(requireObject()).map(([key, item]) => [key, item])
|
||||
case "hasOwn": return Object.hasOwn(requireObject(), String(args[1]))
|
||||
case "values":
|
||||
return Object.values(requireObject())
|
||||
case "entries":
|
||||
return Object.entries(requireObject()).map(([key, item]) => [key, item])
|
||||
case "hasOwn":
|
||||
return Object.hasOwn(requireObject(), String(args[1]))
|
||||
case "assign": {
|
||||
const out: Record<string, unknown> = Object.create(null)
|
||||
for (const source of args) {
|
||||
@@ -948,7 +1151,8 @@ const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode):
|
||||
const value = boundedData(source, "Object.assign input")
|
||||
// A sandbox value source contributes nothing (no own enumerable properties in JS).
|
||||
if (isSandboxValue(value)) continue
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value))
|
||||
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
|
||||
for (const [key, item] of Object.entries(value)) guardedSet(out, key, item)
|
||||
}
|
||||
return out
|
||||
@@ -962,15 +1166,18 @@ const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode):
|
||||
return out
|
||||
}
|
||||
const pairs = boundedData(args[0], "Object.fromEntries input")
|
||||
if (!Array.isArray(pairs)) throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node)
|
||||
if (!Array.isArray(pairs))
|
||||
throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node)
|
||||
const out: Record<string, unknown> = Object.create(null)
|
||||
for (const pair of pairs) {
|
||||
if (!Array.isArray(pair)) throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] pairs.", node)
|
||||
if (!Array.isArray(pair))
|
||||
throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] pairs.", node)
|
||||
guardedSet(out, String(pair[0]), pair[1])
|
||||
}
|
||||
return out
|
||||
}
|
||||
default: throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node)
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -981,23 +1188,40 @@ const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNode): nu
|
||||
})
|
||||
const [a = Number.NaN, b = Number.NaN] = nums
|
||||
switch (name) {
|
||||
case "max": return Math.max(...nums)
|
||||
case "min": return Math.min(...nums)
|
||||
case "abs": return Math.abs(a)
|
||||
case "floor": return Math.floor(a)
|
||||
case "ceil": return Math.ceil(a)
|
||||
case "round": return Math.round(a)
|
||||
case "trunc": return Math.trunc(a)
|
||||
case "sign": return Math.sign(a)
|
||||
case "sqrt": return Math.sqrt(a)
|
||||
case "cbrt": return Math.cbrt(a)
|
||||
case "pow": return Math.pow(a, b)
|
||||
case "hypot": return Math.hypot(...nums)
|
||||
case "log": return Math.log(a)
|
||||
case "log2": return Math.log2(a)
|
||||
case "log10": return Math.log10(a)
|
||||
case "exp": return Math.exp(a)
|
||||
default: throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node)
|
||||
case "max":
|
||||
return Math.max(...nums)
|
||||
case "min":
|
||||
return Math.min(...nums)
|
||||
case "abs":
|
||||
return Math.abs(a)
|
||||
case "floor":
|
||||
return Math.floor(a)
|
||||
case "ceil":
|
||||
return Math.ceil(a)
|
||||
case "round":
|
||||
return Math.round(a)
|
||||
case "trunc":
|
||||
return Math.trunc(a)
|
||||
case "sign":
|
||||
return Math.sign(a)
|
||||
case "sqrt":
|
||||
return Math.sqrt(a)
|
||||
case "cbrt":
|
||||
return Math.cbrt(a)
|
||||
case "pow":
|
||||
return Math.pow(a, b)
|
||||
case "hypot":
|
||||
return Math.hypot(...nums)
|
||||
case "log":
|
||||
return Math.log(a)
|
||||
case "log2":
|
||||
return Math.log2(a)
|
||||
case "log10":
|
||||
return Math.log10(a)
|
||||
case "exp":
|
||||
return Math.exp(a)
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1006,7 +1230,12 @@ const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNode): un
|
||||
case "stringify": {
|
||||
const replacer = args[1]
|
||||
if (Array.isArray(replacer) || replacer instanceof CodeModeFunction) {
|
||||
throw new InterpreterRuntimeError("JSON.stringify replacers are not supported in CodeMode.", node, "UnsupportedSyntax", [supportedSyntaxMessage])
|
||||
throw new InterpreterRuntimeError(
|
||||
"JSON.stringify replacers are not supported in CodeMode.",
|
||||
node,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
const space = args[2]
|
||||
const indent = typeof space === "number" || typeof space === "string" ? space : undefined
|
||||
@@ -1029,7 +1258,8 @@ const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNode): un
|
||||
}
|
||||
return copyIn(parsed, "JSON.parse result")
|
||||
}
|
||||
default: throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node)
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1049,12 +1279,17 @@ const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): u
|
||||
)
|
||||
}
|
||||
// Map/Set materialize directly (the data checkpoint would serialize them to {}).
|
||||
if (args[0] instanceof SandboxMap) return Array.from((args[0] as SandboxMap).map.entries(), ([key, item]) => [key, item])
|
||||
if (args[0] instanceof SandboxMap)
|
||||
return Array.from((args[0] as SandboxMap).map.entries(), ([key, item]) => [key, item])
|
||||
if (args[0] instanceof SandboxSet) return Array.from((args[0] as SandboxSet).set.values())
|
||||
const source = boundedData(args[0], "Array.from input")
|
||||
if (typeof source === "string") return Array.from(source)
|
||||
if (Array.isArray(source)) return [...source]
|
||||
if (source !== null && typeof source === "object" && typeof (source as { length?: unknown }).length === "number") {
|
||||
if (
|
||||
source !== null &&
|
||||
typeof source === "object" &&
|
||||
typeof (source as { length?: unknown }).length === "number"
|
||||
) {
|
||||
return Array.from(source as ArrayLike<unknown>)
|
||||
}
|
||||
throw new InterpreterRuntimeError("Array.from expects an array, string, Map, Set, or array-like value.", node)
|
||||
@@ -1067,17 +1302,24 @@ const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): u
|
||||
const invokeNumberStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
const value = args[0]
|
||||
switch (name) {
|
||||
case "isInteger": return Number.isInteger(value)
|
||||
case "isFinite": return Number.isFinite(value)
|
||||
case "isNaN": return Number.isNaN(value)
|
||||
case "isSafeInteger": return Number.isSafeInteger(value)
|
||||
case "isInteger":
|
||||
return Number.isInteger(value)
|
||||
case "isFinite":
|
||||
return Number.isFinite(value)
|
||||
case "isNaN":
|
||||
return Number.isNaN(value)
|
||||
case "isSafeInteger":
|
||||
return Number.isSafeInteger(value)
|
||||
case "parseInt": {
|
||||
const radix = args[1]
|
||||
if (radix !== undefined && typeof radix !== "number") throw new InterpreterRuntimeError("Number.parseInt expects a numeric radix.", node)
|
||||
if (radix !== undefined && typeof radix !== "number")
|
||||
throw new InterpreterRuntimeError("Number.parseInt expects a numeric radix.", node)
|
||||
return parseInt(coerceToString(value), radix)
|
||||
}
|
||||
case "parseFloat": return parseFloat(coerceToString(value))
|
||||
default: throw new InterpreterRuntimeError(`Number.${name} is not available in CodeMode.`, node)
|
||||
case "parseFloat":
|
||||
return parseFloat(coerceToString(value))
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Number.${name} is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1087,54 +1329,82 @@ const invokeStringStatic = (name: string, args: Array<unknown>, node: AstNode):
|
||||
return arg
|
||||
})
|
||||
switch (name) {
|
||||
case "fromCharCode": return String.fromCharCode(...codes)
|
||||
case "fromCodePoint": return String.fromCodePoint(...codes)
|
||||
default: throw new InterpreterRuntimeError(`String.${name} is not available in CodeMode.`, node)
|
||||
case "fromCharCode":
|
||||
return String.fromCharCode(...codes)
|
||||
case "fromCodePoint":
|
||||
return String.fromCodePoint(...codes)
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`String.${name} is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNode): number => {
|
||||
switch (name) {
|
||||
case "now": return Date.now()
|
||||
case "parse": return Date.parse(coerceToString(args[0]))
|
||||
case "now":
|
||||
return Date.now()
|
||||
case "parse":
|
||||
return Date.parse(coerceToString(args[0]))
|
||||
case "UTC": {
|
||||
const parts = args.map((arg) => coerceToNumber(arg))
|
||||
return Date.UTC(...(parts as Parameters<typeof Date.UTC>))
|
||||
}
|
||||
default: throw new InterpreterRuntimeError(`Date.${name} is not available in CodeMode.`, node)
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Date.${name} is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeDateMethod = (value: SandboxDate, name: string, node: AstNode): unknown => {
|
||||
const hosted = new Date(value.time)
|
||||
switch (name) {
|
||||
case "getTime": case "valueOf": return value.time
|
||||
case "getTime":
|
||||
case "valueOf":
|
||||
return value.time
|
||||
case "toISOString": {
|
||||
if (!Number.isFinite(value.time)) throw new InterpreterRuntimeError("Invalid time value.", node)
|
||||
return hosted.toISOString()
|
||||
}
|
||||
// toJSON of an invalid date is null in JS (never a throw); toString stays ISO for
|
||||
// determinism across host timezones/locales.
|
||||
case "toJSON": return Number.isFinite(value.time) ? hosted.toISOString() : null
|
||||
case "toString": return coerceToString(value)
|
||||
case "getFullYear": return hosted.getFullYear()
|
||||
case "getMonth": return hosted.getMonth()
|
||||
case "getDate": return hosted.getDate()
|
||||
case "getDay": return hosted.getDay()
|
||||
case "getHours": return hosted.getHours()
|
||||
case "getMinutes": return hosted.getMinutes()
|
||||
case "getSeconds": return hosted.getSeconds()
|
||||
case "getMilliseconds": return hosted.getMilliseconds()
|
||||
case "getUTCFullYear": return hosted.getUTCFullYear()
|
||||
case "getUTCMonth": return hosted.getUTCMonth()
|
||||
case "getUTCDate": return hosted.getUTCDate()
|
||||
case "getUTCDay": return hosted.getUTCDay()
|
||||
case "getUTCHours": return hosted.getUTCHours()
|
||||
case "getUTCMinutes": return hosted.getUTCMinutes()
|
||||
case "getUTCSeconds": return hosted.getUTCSeconds()
|
||||
case "getUTCMilliseconds": return hosted.getUTCMilliseconds()
|
||||
case "getTimezoneOffset": return hosted.getTimezoneOffset()
|
||||
default: throw new InterpreterRuntimeError(`Date method '${name}' is not available in CodeMode.`, node)
|
||||
case "toJSON":
|
||||
return Number.isFinite(value.time) ? hosted.toISOString() : null
|
||||
case "toString":
|
||||
return coerceToString(value)
|
||||
case "getFullYear":
|
||||
return hosted.getFullYear()
|
||||
case "getMonth":
|
||||
return hosted.getMonth()
|
||||
case "getDate":
|
||||
return hosted.getDate()
|
||||
case "getDay":
|
||||
return hosted.getDay()
|
||||
case "getHours":
|
||||
return hosted.getHours()
|
||||
case "getMinutes":
|
||||
return hosted.getMinutes()
|
||||
case "getSeconds":
|
||||
return hosted.getSeconds()
|
||||
case "getMilliseconds":
|
||||
return hosted.getMilliseconds()
|
||||
case "getUTCFullYear":
|
||||
return hosted.getUTCFullYear()
|
||||
case "getUTCMonth":
|
||||
return hosted.getUTCMonth()
|
||||
case "getUTCDate":
|
||||
return hosted.getUTCDate()
|
||||
case "getUTCDay":
|
||||
return hosted.getUTCDay()
|
||||
case "getUTCHours":
|
||||
return hosted.getUTCHours()
|
||||
case "getUTCMinutes":
|
||||
return hosted.getUTCMinutes()
|
||||
case "getUTCSeconds":
|
||||
return hosted.getUTCSeconds()
|
||||
case "getUTCMilliseconds":
|
||||
return hosted.getUTCMilliseconds()
|
||||
case "getTimezoneOffset":
|
||||
return hosted.getTimezoneOffset()
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Date method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1142,26 +1412,31 @@ const invokeRegExpMethod = (value: SandboxRegExp, name: string, args: Array<unkn
|
||||
switch (name) {
|
||||
// test/exec run on the sandbox regex's own host instance, so `g`-flag lastIndex advances
|
||||
// across calls per the spec.
|
||||
case "test": return value.regex.test(coerceToString(args[0]))
|
||||
case "test":
|
||||
return value.regex.test(coerceToString(args[0]))
|
||||
case "exec": {
|
||||
const matched = value.regex.exec(coerceToString(args[0]))
|
||||
if (matched === null) return null
|
||||
return matchToValue(matched)
|
||||
}
|
||||
case "toString": return coerceToString(value)
|
||||
default: throw new InterpreterRuntimeError(`RegExp method '${name}' is not available in CodeMode.`, node)
|
||||
case "toString":
|
||||
return coerceToString(value)
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`RegExp method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, node: AstNode): unknown => {
|
||||
if (ref.namespace === "console") throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node)
|
||||
if (ref.namespace === "console")
|
||||
throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node)
|
||||
if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node)
|
||||
if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node)
|
||||
if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node)
|
||||
if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node)
|
||||
if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node)
|
||||
if (ref.namespace === "Date") {
|
||||
if (!dateStatics.has(ref.name)) throw new InterpreterRuntimeError(`Date.${ref.name} is not available in CodeMode.`, node)
|
||||
if (!dateStatics.has(ref.name))
|
||||
throw new InterpreterRuntimeError(`Date.${ref.name} is not available in CodeMode.`, node)
|
||||
return invokeDateStatic(ref.name, args, node)
|
||||
}
|
||||
if (ref.namespace === "RegExp" || ref.namespace === "Map" || ref.namespace === "Set") {
|
||||
@@ -1174,7 +1449,8 @@ const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, no
|
||||
const spreadItems = (spread: unknown): Array<unknown> | undefined => {
|
||||
if (Array.isArray(spread)) return spread
|
||||
if (typeof spread === "string") return Array.from(spread)
|
||||
if (spread instanceof SandboxMap) return Array.from(spread.map.entries(), ([key, item]): Array<unknown> => [key, item])
|
||||
if (spread instanceof SandboxMap)
|
||||
return Array.from(spread.map.entries(), ([key, item]): Array<unknown> => [key, item])
|
||||
if (spread instanceof SandboxSet) return Array.from(spread.set.values())
|
||||
return undefined
|
||||
}
|
||||
@@ -1267,7 +1543,7 @@ class Interpreter<R> {
|
||||
// top-level declarations (`let undefined = 5`, `const Object = ...`) shadow builtins like
|
||||
// JS module scope, instead of colliding with the seeded globals.
|
||||
this.pushScope()
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
self.hoistFunctions(program.body)
|
||||
let value: unknown = undefined
|
||||
let returned = false
|
||||
@@ -1305,7 +1581,7 @@ class Interpreter<R> {
|
||||
// diagnostic (interrupted calls, e.g. Promise.race losers, are ignored).
|
||||
private drainPendingSettlements(): Effect.Effect<void, unknown, never> {
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
for (const promise of [...self.pendingSettlements]) {
|
||||
const exit = yield* self.observePromise(promise)
|
||||
if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) continue
|
||||
@@ -1324,13 +1600,15 @@ class Interpreter<R> {
|
||||
// scope teardown interrupt it) gated by the concurrency semaphore, and wraps the fiber in a
|
||||
// first-class promise value. `startImmediately` makes the runtime admit the call - charging
|
||||
// the tool-call budget and firing onToolCallStart - at the call site, before any await.
|
||||
private createToolCallPromise(path: ReadonlyArray<string>, args: Array<unknown>): Effect.Effect<SandboxPromise, never, R> {
|
||||
private createToolCallPromise(
|
||||
path: ReadonlyArray<string>,
|
||||
args: Array<unknown>,
|
||||
): Effect.Effect<SandboxPromise, never, R> {
|
||||
const self = this
|
||||
return Effect.map(
|
||||
Effect.forkChild(
|
||||
this.callPermits.withPermit(Effect.suspend(() => self.invokeTool(path, args))),
|
||||
{ startImmediately: true },
|
||||
),
|
||||
Effect.forkChild(this.callPermits.withPermit(Effect.suspend(() => self.invokeTool(path, args))), {
|
||||
startImmediately: true,
|
||||
}),
|
||||
(fiber) => {
|
||||
const promise = new SandboxPromise(fiber)
|
||||
self.pendingSettlements.add(promise)
|
||||
@@ -1354,13 +1632,22 @@ class Interpreter<R> {
|
||||
return Effect.flatMap(this.observePromise(promise), (exit) => self.unwrapPromiseExit(promise, exit, node))
|
||||
}
|
||||
|
||||
private unwrapPromiseExit(promise: SandboxPromise | undefined, exit: Exit.Exit<unknown, unknown>, node?: AstNode): Effect.Effect<unknown, unknown> {
|
||||
private unwrapPromiseExit(
|
||||
promise: SandboxPromise | undefined,
|
||||
exit: Exit.Exit<unknown, unknown>,
|
||||
node?: AstNode,
|
||||
): Effect.Effect<unknown, unknown> {
|
||||
if (Exit.isSuccess(exit)) return Effect.succeed(exit.value)
|
||||
// A call Promise.race interrupted after losing settles as a catchable program failure;
|
||||
// any other interruption is execution teardown (timeout/host) and must keep propagating
|
||||
// as interruption rather than becoming program-visible data.
|
||||
if (promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)) {
|
||||
return Effect.fail(new InterpreterRuntimeError("This tool call was interrupted because another value settled a Promise.race first.", node))
|
||||
return Effect.fail(
|
||||
new InterpreterRuntimeError(
|
||||
"This tool call was interrupted because another value settled a Promise.race first.",
|
||||
node,
|
||||
),
|
||||
)
|
||||
}
|
||||
return Effect.failCause(exit.cause)
|
||||
}
|
||||
@@ -1413,7 +1700,7 @@ class Interpreter<R> {
|
||||
private evaluateBlock(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
this.pushScope()
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
const body = getArray(node, "body")
|
||||
self.hoistFunctions(body)
|
||||
|
||||
@@ -1437,7 +1724,12 @@ class Interpreter<R> {
|
||||
|
||||
private createFunction(node: AstNode): CodeModeFunction {
|
||||
if (node.generator === true) {
|
||||
throw new InterpreterRuntimeError("Generator functions are not supported in CodeMode.", node, "UnsupportedSyntax", [supportedSyntaxMessage])
|
||||
throw new InterpreterRuntimeError(
|
||||
"Generator functions are not supported in CodeMode.",
|
||||
node,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
return new CodeModeFunction(
|
||||
getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)),
|
||||
@@ -1462,16 +1754,25 @@ class Interpreter<R> {
|
||||
const alternateNode = getOptionalNode(node, "alternate")
|
||||
|
||||
return Effect.flatMap(this.evaluateExpression(testNode), (test) =>
|
||||
test ? this.evaluateStatement(consequentNode) : alternateNode ? this.evaluateStatement(alternateNode) : Effect.succeed({ kind: "none" }))
|
||||
test
|
||||
? this.evaluateStatement(consequentNode)
|
||||
: alternateNode
|
||||
? this.evaluateStatement(alternateNode)
|
||||
: Effect.succeed({ kind: "none" }),
|
||||
)
|
||||
}
|
||||
|
||||
private evaluateSwitchStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
const self = this
|
||||
this.pushScope()
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant"))
|
||||
if (containsOpaqueReference(discriminant)) {
|
||||
throw new InterpreterRuntimeError("Switch discriminants must be data values in CodeMode.", node, "InvalidDataValue")
|
||||
throw new InterpreterRuntimeError(
|
||||
"Switch discriminants must be data values in CodeMode.",
|
||||
node,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
const cases = getArray(node, "cases").map((value, index) => asNode(value, `cases[${index}]`))
|
||||
let defaultIndex: number | undefined
|
||||
@@ -1484,7 +1785,11 @@ class Interpreter<R> {
|
||||
}
|
||||
const candidate = yield* self.evaluateExpression(test)
|
||||
if (containsOpaqueReference(candidate)) {
|
||||
throw new InterpreterRuntimeError("Switch case values must be data values in CodeMode.", test, "InvalidDataValue")
|
||||
throw new InterpreterRuntimeError(
|
||||
"Switch case values must be data values in CodeMode.",
|
||||
test,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
if (candidate === discriminant) {
|
||||
selected = index
|
||||
@@ -1510,7 +1815,7 @@ class Interpreter<R> {
|
||||
const bodyNode = getNode(node, "body")
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
while (yield* self.evaluateExpression(testNode)) {
|
||||
const result = yield* self.evaluateStatement(bodyNode)
|
||||
|
||||
@@ -1519,7 +1824,7 @@ class Interpreter<R> {
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}
|
||||
|
||||
if (result.kind === "return") {
|
||||
@@ -1540,7 +1845,7 @@ class Interpreter<R> {
|
||||
const testNode = getNode(node, "test")
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
do {
|
||||
const result = yield* self.evaluateStatement(bodyNode)
|
||||
|
||||
@@ -1568,7 +1873,7 @@ class Interpreter<R> {
|
||||
private evaluateForStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
this.pushScope()
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
const initNode = getOptionalNode(node, "init")
|
||||
const testNode = getOptionalNode(node, "test")
|
||||
const updateNode = getOptionalNode(node, "update")
|
||||
@@ -1582,23 +1887,28 @@ class Interpreter<R> {
|
||||
}
|
||||
}
|
||||
|
||||
const perIterationBindings = initNode?.type === "VariableDeclaration" && getString(initNode, "kind") !== "var"
|
||||
? Array.from(self.currentScope().keys())
|
||||
: []
|
||||
const perIterationBindings =
|
||||
initNode?.type === "VariableDeclaration" && getString(initNode, "kind") !== "var"
|
||||
? Array.from(self.currentScope().keys())
|
||||
: []
|
||||
|
||||
while (testNode ? yield* self.evaluateExpression(testNode) : true) {
|
||||
let iterationScope: Map<string, Binding> | undefined
|
||||
if (perIterationBindings.length > 0) {
|
||||
iterationScope = new Map(perIterationBindings.map((name) => {
|
||||
const binding = self.currentScope().get(name)!
|
||||
return [name, { ...binding }]
|
||||
}))
|
||||
iterationScope = new Map(
|
||||
perIterationBindings.map((name) => {
|
||||
const binding = self.currentScope().get(name)!
|
||||
return [name, { ...binding }]
|
||||
}),
|
||||
)
|
||||
self.scopes.push(iterationScope)
|
||||
}
|
||||
const result = yield* self.evaluateStatement(bodyNode).pipe(
|
||||
Effect.ensuring(Effect.sync(() => {
|
||||
if (iterationScope) self.popScope()
|
||||
})),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
if (iterationScope) self.popScope()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
if (result.kind === "return") {
|
||||
@@ -1639,7 +1949,7 @@ class Interpreter<R> {
|
||||
}
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
const left = getNode(node, "left")
|
||||
const right = yield* self.evaluateExpression(getNode(node, "right"))
|
||||
const body = getNode(node, "body")
|
||||
@@ -1677,9 +1987,11 @@ class Interpreter<R> {
|
||||
}
|
||||
|
||||
const result = yield* self.evaluateStatement(body).pipe(
|
||||
Effect.ensuring(Effect.sync(() => {
|
||||
if (declaration) self.popScope()
|
||||
})),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
if (declaration) self.popScope()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
if (result.kind === "return") {
|
||||
@@ -1723,7 +2035,7 @@ class Interpreter<R> {
|
||||
|
||||
private evaluateForInStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
const left = getNode(node, "left")
|
||||
const right = yield* self.evaluateExpression(getNode(node, "right"))
|
||||
const body = getNode(node, "body")
|
||||
@@ -1768,9 +2080,11 @@ class Interpreter<R> {
|
||||
}
|
||||
|
||||
const result = yield* self.evaluateStatement(body).pipe(
|
||||
Effect.ensuring(Effect.sync(() => {
|
||||
if (declaration) self.popScope()
|
||||
})),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
if (declaration) self.popScope()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
if (result.kind === "return") {
|
||||
@@ -1836,12 +2150,10 @@ class Interpreter<R> {
|
||||
const caught = caughtErrorValue(Cause.squash(cause))
|
||||
const parameter = getOptionalNode(handler, "param")
|
||||
self.pushScope()
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
if (parameter) yield* self.declarePattern(parameter, caught, true, handler)
|
||||
return yield* self.evaluateStatement(getNode(handler, "body"))
|
||||
}).pipe(
|
||||
Effect.ensuring(Effect.sync(() => self.popScope())),
|
||||
)
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
|
||||
},
|
||||
onSuccess: Effect.succeed,
|
||||
})
|
||||
@@ -1856,10 +2168,12 @@ class Interpreter<R> {
|
||||
cause.reasons.some(Cause.isInterruptReason)
|
||||
? Effect.failCause(cause)
|
||||
: Effect.flatMap(this.evaluateStatement(finalizer), (final) =>
|
||||
isAbrupt(final) ? Effect.succeed(final) : Effect.failCause(cause)),
|
||||
isAbrupt(final) ? Effect.succeed(final) : Effect.failCause(cause),
|
||||
),
|
||||
onSuccess: (result) =>
|
||||
Effect.flatMap(this.evaluateStatement(finalizer), (final) =>
|
||||
isAbrupt(final) ? Effect.succeed(final) : Effect.succeed(result)),
|
||||
isAbrupt(final) ? Effect.succeed(final) : Effect.succeed(result),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1867,7 +2181,7 @@ class Interpreter<R> {
|
||||
const kind = getString(node, "kind")
|
||||
const declarations = getArray(node, "declarations")
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
for (const declarationValue of declarations) {
|
||||
const declaration = asNode(declarationValue, "declarations")
|
||||
|
||||
@@ -1882,9 +2196,14 @@ class Interpreter<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private declarePattern(pattern: AstNode, value: unknown, mutable: boolean, node: AstNode): Effect.Effect<void, unknown, R> {
|
||||
private declarePattern(
|
||||
pattern: AstNode,
|
||||
value: unknown,
|
||||
mutable: boolean,
|
||||
node: AstNode,
|
||||
): Effect.Effect<void, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
if (pattern.type === "Identifier") {
|
||||
self.declare(getString(pattern, "name"), value, mutable, node)
|
||||
return
|
||||
@@ -1899,7 +2218,11 @@ class Interpreter<R> {
|
||||
|
||||
if (pattern.type === "ObjectPattern") {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value) || isRuntimeReference(value)) {
|
||||
throw new InterpreterRuntimeError("Object destructuring requires a data object value.", pattern, "InvalidDataValue")
|
||||
throw new InterpreterRuntimeError(
|
||||
"Object destructuring requires a data object value.",
|
||||
pattern,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
|
||||
const consumed = new Set<string>()
|
||||
@@ -1916,7 +2239,11 @@ class Interpreter<R> {
|
||||
continue
|
||||
}
|
||||
|
||||
if (property.type !== "Property" || getBoolean(property, "computed") || getString(property, "kind") !== "init") {
|
||||
if (
|
||||
property.type !== "Property" ||
|
||||
getBoolean(property, "computed") ||
|
||||
getString(property, "kind") !== "init"
|
||||
) {
|
||||
throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property)
|
||||
}
|
||||
|
||||
@@ -1960,7 +2287,9 @@ class Interpreter<R> {
|
||||
// sandbox regex from those (the host `value` instance is never exposed).
|
||||
const regex = node.regex
|
||||
if (isRecord(regex) && typeof regex.pattern === "string") {
|
||||
return Effect.sync(() => this.constructRegExp([regex.pattern, typeof regex.flags === "string" ? regex.flags : ""], node))
|
||||
return Effect.sync(() =>
|
||||
this.constructRegExp([regex.pattern, typeof regex.flags === "string" ? regex.flags : ""], node),
|
||||
)
|
||||
}
|
||||
return Effect.sync(() => boundedData(node.value, "Literal"))
|
||||
}
|
||||
@@ -1983,7 +2312,8 @@ class Interpreter<R> {
|
||||
return this.readMember(node)
|
||||
case "ChainExpression":
|
||||
return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) =>
|
||||
value === OptionalShortCircuit ? undefined : value)
|
||||
value === OptionalShortCircuit ? undefined : value,
|
||||
)
|
||||
case "ObjectExpression":
|
||||
return this.evaluateObjectExpression(node)
|
||||
case "ArrayExpression":
|
||||
@@ -1999,7 +2329,8 @@ class Interpreter<R> {
|
||||
// matching real JS semantics for non-thenables.
|
||||
const self = this
|
||||
return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) =>
|
||||
value instanceof SandboxPromise ? self.settlePromise(value, node) : Effect.succeed(value))
|
||||
value instanceof SandboxPromise ? self.settlePromise(value, node) : Effect.succeed(value),
|
||||
)
|
||||
}
|
||||
case "NewExpression":
|
||||
return this.evaluateNewExpression(node)
|
||||
@@ -2025,19 +2356,24 @@ class Interpreter<R> {
|
||||
)
|
||||
}
|
||||
if (errorConstructors.has(name)) {
|
||||
return Effect.gen(function*() {
|
||||
const arg = argNodes.length > 0 ? yield* self.evaluateExpression(asNode(argNodes[0], "arguments[0]")) : undefined
|
||||
return Effect.gen(function* () {
|
||||
const arg =
|
||||
argNodes.length > 0 ? yield* self.evaluateExpression(asNode(argNodes[0], "arguments[0]")) : undefined
|
||||
return createErrorValue(name, arg === undefined ? "" : coerceToString(arg))
|
||||
})
|
||||
}
|
||||
if (valueConstructors.has(name)) {
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
const args = yield* self.evaluateCallArguments(argNodes)
|
||||
switch (name) {
|
||||
case "Date": return self.constructDate(args)
|
||||
case "RegExp": return self.constructRegExp(args, node)
|
||||
case "Map": return self.constructMap(args[0], node)
|
||||
default: return self.constructSet(args[0], node)
|
||||
case "Date":
|
||||
return self.constructDate(args)
|
||||
case "RegExp":
|
||||
return self.constructRegExp(args, node)
|
||||
case "Map":
|
||||
return self.constructMap(args[0], node)
|
||||
default:
|
||||
return self.constructSet(args[0], node)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2060,7 +2396,8 @@ class Interpreter<R> {
|
||||
|
||||
private constructRegExp(args: Array<unknown>, node: AstNode): SandboxRegExp {
|
||||
const first = args[0]
|
||||
const pattern = first instanceof SandboxRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first)
|
||||
const pattern =
|
||||
first instanceof SandboxRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first)
|
||||
const flagsArg = args[1]
|
||||
if (flagsArg !== undefined && typeof flagsArg !== "string") {
|
||||
throw new InterpreterRuntimeError(
|
||||
@@ -2094,7 +2431,10 @@ class Interpreter<R> {
|
||||
? Array.from(init.map.entries(), ([key, item]): Array<unknown> => [key, item])
|
||||
: undefined
|
||||
if (entries === undefined) {
|
||||
throw new InterpreterRuntimeError("new Map(...) expects an array of [key, value] pairs, a Map, or no argument.", node)
|
||||
throw new InterpreterRuntimeError(
|
||||
"new Map(...) expects an array of [key, value] pairs, a Map, or no argument.",
|
||||
node,
|
||||
)
|
||||
}
|
||||
for (const pair of entries) {
|
||||
if (!Array.isArray(pair)) {
|
||||
@@ -2122,12 +2462,10 @@ class Interpreter<R> {
|
||||
return target
|
||||
}
|
||||
|
||||
|
||||
|
||||
private evaluateBinaryExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const operator = getString(node, "operator")
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
const lhs = yield* self.evaluateExpression(getNode(node, "left"))
|
||||
const rhs = yield* self.evaluateExpression(getNode(node, "right"))
|
||||
// Like `typeof`, `instanceof` observes any value without coercing it (a promise or
|
||||
@@ -2161,34 +2499,55 @@ class Interpreter<R> {
|
||||
const l = coerceOperand(lhs)
|
||||
const r = coerceOperand(rhs)
|
||||
switch (operator) {
|
||||
case "+": return (l as string) + (r as string)
|
||||
case "-": return (l as number) - (r as number)
|
||||
case "*": return (l as number) * (r as number)
|
||||
case "/": return (l as number) / (r as number)
|
||||
case "%": return (l as number) % (r as number)
|
||||
case "**": return (l as number) ** (r as number)
|
||||
case "+":
|
||||
return (l as string) + (r as string)
|
||||
case "-":
|
||||
return (l as number) - (r as number)
|
||||
case "*":
|
||||
return (l as number) * (r as number)
|
||||
case "/":
|
||||
return (l as number) / (r as number)
|
||||
case "%":
|
||||
return (l as number) % (r as number)
|
||||
case "**":
|
||||
return (l as number) ** (r as number)
|
||||
// Two objects compare by identity in JS (no ToPrimitive); only object-vs-primitive coerces.
|
||||
case "==": return bothObjects ? lhs === rhs : l == r
|
||||
case "===": return lhs === rhs
|
||||
case "!=": return bothObjects ? lhs !== rhs : l != r
|
||||
case "!==": return lhs !== rhs
|
||||
case "<": return (l as string) < (r as string)
|
||||
case "<=": return (l as string) <= (r as string)
|
||||
case ">": return (l as string) > (r as string)
|
||||
case ">=": return (l as string) >= (r as string)
|
||||
case "&": return (l as number) & (r as number)
|
||||
case "|": return (l as number) | (r as number)
|
||||
case "^": return (l as number) ^ (r as number)
|
||||
case "<<": return (l as number) << (r as number)
|
||||
case ">>": return (l as number) >> (r as number)
|
||||
case ">>>": return (l as number) >>> (r as number)
|
||||
case "==":
|
||||
return bothObjects ? lhs === rhs : l == r
|
||||
case "===":
|
||||
return lhs === rhs
|
||||
case "!=":
|
||||
return bothObjects ? lhs !== rhs : l != r
|
||||
case "!==":
|
||||
return lhs !== rhs
|
||||
case "<":
|
||||
return (l as string) < (r as string)
|
||||
case "<=":
|
||||
return (l as string) <= (r as string)
|
||||
case ">":
|
||||
return (l as string) > (r as string)
|
||||
case ">=":
|
||||
return (l as string) >= (r as string)
|
||||
case "&":
|
||||
return (l as number) & (r as number)
|
||||
case "|":
|
||||
return (l as number) | (r as number)
|
||||
case "^":
|
||||
return (l as number) ^ (r as number)
|
||||
case "<<":
|
||||
return (l as number) << (r as number)
|
||||
case ">>":
|
||||
return (l as number) >> (r as number)
|
||||
case ">>>":
|
||||
return (l as number) >>> (r as number)
|
||||
case "in":
|
||||
if (rhs === null || typeof rhs !== "object") {
|
||||
throw new InterpreterRuntimeError("The 'in' operator requires a data object on the right-hand side.", node)
|
||||
}
|
||||
// Own properties only, so arrays don't leak the host Array.prototype (map/constructor/...).
|
||||
return Object.hasOwn(rhs as object, coerceOperand(lhs) as PropertyKey)
|
||||
default: throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node)
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2197,7 +2556,10 @@ class Interpreter<R> {
|
||||
return Effect.flatMap(this.evaluateExpression(getNode(node, "left")), (left) => {
|
||||
if (operator === "&&") return left ? this.evaluateExpression(getNode(node, "right")) : Effect.succeed(left)
|
||||
if (operator === "||") return left ? Effect.succeed(left) : this.evaluateExpression(getNode(node, "right"))
|
||||
if (operator === "??") return left !== null && left !== undefined ? Effect.succeed(left) : this.evaluateExpression(getNode(node, "right"))
|
||||
if (operator === "??")
|
||||
return left !== null && left !== undefined
|
||||
? Effect.succeed(left)
|
||||
: this.evaluateExpression(getNode(node, "right"))
|
||||
throw new InterpreterRuntimeError(`Unsupported logical operator '${operator}'.`, node)
|
||||
})
|
||||
}
|
||||
@@ -2222,17 +2584,25 @@ class Interpreter<R> {
|
||||
// Numeric/bitwise unary operators ToPrimitive their operand; a Date yields its time value
|
||||
// (`+date` is the epoch-ms idiom), other null-prototype data objects/arrays coerce to
|
||||
// their JS string form first (see evaluateBinaryExpression).
|
||||
const operand = value instanceof SandboxDate
|
||||
? value.time
|
||||
: value !== null && typeof value === "object"
|
||||
? coerceToString(value)
|
||||
: value
|
||||
const operand =
|
||||
value instanceof SandboxDate
|
||||
? value.time
|
||||
: value !== null && typeof value === "object"
|
||||
? coerceToString(value)
|
||||
: value
|
||||
let result: unknown
|
||||
switch (operator) {
|
||||
case "+": result = +(operand as number); break
|
||||
case "-": result = -(operand as number); break
|
||||
case "~": result = ~(operand as number); break
|
||||
default: throw new InterpreterRuntimeError(`Unsupported unary operator '${operator}'.`, node)
|
||||
case "+":
|
||||
result = +(operand as number)
|
||||
break
|
||||
case "-":
|
||||
result = -(operand as number)
|
||||
break
|
||||
case "~":
|
||||
result = ~(operand as number)
|
||||
break
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Unsupported unary operator '${operator}'.`, node)
|
||||
}
|
||||
return boundedData(result, "Unary expression result")
|
||||
})
|
||||
@@ -2242,7 +2612,7 @@ class Interpreter<R> {
|
||||
const left = getNode(node, "left")
|
||||
const operator = getString(node, "operator")
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
if (operator === "??=" || operator === "||=" || operator === "&&=") {
|
||||
return yield* self.evaluateLogicalAssignment(node, left, operator)
|
||||
}
|
||||
@@ -2250,13 +2620,19 @@ class Interpreter<R> {
|
||||
if (left.type === "Identifier") {
|
||||
const name = getString(left, "name")
|
||||
if (operator === "=") return self.setIdentifierValue(name, rightValue, left)
|
||||
const next = boundedData(self.applyCompoundAssignment(operator, self.getIdentifierValue(name, left), rightValue, node), "Assignment result")
|
||||
const next = boundedData(
|
||||
self.applyCompoundAssignment(operator, self.getIdentifierValue(name, left), rightValue, node),
|
||||
"Assignment result",
|
||||
)
|
||||
return self.setIdentifierValue(name, next, left)
|
||||
}
|
||||
if (left.type === "MemberExpression") {
|
||||
if (operator === "=") return yield* self.writeMember(left, rightValue)
|
||||
return yield* self.modifyMember(left, (current) => {
|
||||
const next = boundedData(self.applyCompoundAssignment(operator, current, rightValue, node), "Assignment result")
|
||||
const next = boundedData(
|
||||
self.applyCompoundAssignment(operator, current, rightValue, node),
|
||||
"Assignment result",
|
||||
)
|
||||
return Effect.succeed({ write: true, next, result: next })
|
||||
})
|
||||
}
|
||||
@@ -2264,13 +2640,17 @@ class Interpreter<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateLogicalAssignment(node: AstNode, left: AstNode, operator: string): Effect.Effect<unknown, unknown, R> {
|
||||
private evaluateLogicalAssignment(
|
||||
node: AstNode,
|
||||
left: AstNode,
|
||||
operator: string,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
const self = this
|
||||
const shouldAssign = (current: unknown): boolean =>
|
||||
operator === "??=" ? current === null || current === undefined : operator === "||=" ? !current : Boolean(current)
|
||||
if (left.type === "Identifier") {
|
||||
const name = getString(left, "name")
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
const current = self.getIdentifierValue(name, left)
|
||||
if (!shouldAssign(current)) return current
|
||||
const rightValue = yield* self.evaluateExpression(getNode(node, "right"))
|
||||
@@ -2281,8 +2661,13 @@ class Interpreter<R> {
|
||||
// Resolve the member exactly once; evaluate the RHS only if we actually assign.
|
||||
return self.modifyMember(left, (current) =>
|
||||
shouldAssign(current)
|
||||
? Effect.map(self.evaluateExpression(getNode(node, "right")), (rightValue) => ({ write: true, next: rightValue, result: rightValue }))
|
||||
: Effect.succeed({ write: false, next: current, result: current }))
|
||||
? Effect.map(self.evaluateExpression(getNode(node, "right")), (rightValue) => ({
|
||||
write: true,
|
||||
next: rightValue,
|
||||
result: rightValue,
|
||||
}))
|
||||
: Effect.succeed({ write: false, next: current, result: current }),
|
||||
)
|
||||
}
|
||||
throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left)
|
||||
}
|
||||
@@ -2324,7 +2709,7 @@ class Interpreter<R> {
|
||||
const argNodes = getArray(node, "arguments")
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
const callable = yield* self.evaluateExpression(callee)
|
||||
if (callable === OptionalShortCircuit) return OptionalShortCircuit
|
||||
if ((callable === null || callable === undefined) && node.optional === true) return OptionalShortCircuit
|
||||
@@ -2359,7 +2744,7 @@ class Interpreter<R> {
|
||||
if (callable instanceof ErrorConstructorReference) {
|
||||
return createErrorValue(callable.name, args[0] === undefined ? "" : coerceToString(args[0]))
|
||||
}
|
||||
throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee)
|
||||
throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2379,7 +2764,8 @@ class Interpreter<R> {
|
||||
}
|
||||
|
||||
private invokeConsole(name: string, args: Array<unknown>, node: AstNode): undefined {
|
||||
if (!consoleMethods.has(name)) throw new InterpreterRuntimeError(`console.${name} is not available in CodeMode.`, node)
|
||||
if (!consoleMethods.has(name))
|
||||
throw new InterpreterRuntimeError(`console.${name} is not available in CodeMode.`, node)
|
||||
this.logs.push(publicErrorMessage(this.formatConsoleMessage(name, args, node)))
|
||||
return undefined
|
||||
}
|
||||
@@ -2439,7 +2825,9 @@ class Interpreter<R> {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => this.formatConsoleValue(item, seen, depth + 1)).join(",")}]`
|
||||
}
|
||||
return `{${Object.entries(value).map(([key, item]) => `${JSON.stringify(key)}:${this.formatConsoleValue(item, seen, depth + 1)}`).join(",")}}`
|
||||
return `{${Object.entries(value)
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${this.formatConsoleValue(item, seen, depth + 1)}`)
|
||||
.join(",")}}`
|
||||
} finally {
|
||||
seen.delete(value)
|
||||
}
|
||||
@@ -2455,7 +2843,10 @@ class Interpreter<R> {
|
||||
const rows = this.consoleTableRows(data, columns)
|
||||
const keys = columns ?? Array.from(new Set(rows.flatMap((row) => Object.keys(row.values))))
|
||||
const header = ["(index)", ...keys].join("\t")
|
||||
return [header, ...rows.map((row) => [row.index, ...keys.map((key) => this.formatConsoleTableCell(row.values[key]))].join("\t"))].join("\n")
|
||||
return [
|
||||
header,
|
||||
...rows.map((row) => [row.index, ...keys.map((key) => this.formatConsoleTableCell(row.values[key]))].join("\t")),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
private consoleTableColumns(value: unknown, node: AstNode): ReadonlyArray<string> | undefined {
|
||||
@@ -2465,7 +2856,10 @@ class Interpreter<R> {
|
||||
return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined
|
||||
}
|
||||
|
||||
private consoleTableRows(data: unknown, columns: ReadonlyArray<string> | undefined): Array<{ readonly index: string; readonly values: Record<string, unknown> }> {
|
||||
private consoleTableRows(
|
||||
data: unknown,
|
||||
columns: ReadonlyArray<string> | undefined,
|
||||
): Array<{ readonly index: string; readonly values: Record<string, unknown> }> {
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item, index) => ({ index: String(index), values: this.consoleTableValues(item, columns) }))
|
||||
}
|
||||
@@ -2492,14 +2886,18 @@ class Interpreter<R> {
|
||||
|
||||
private evaluateCallArguments(argNodes: Array<unknown>): Effect.Effect<Array<unknown>, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
const args: Array<unknown> = []
|
||||
for (const [index, arg] of argNodes.entries()) {
|
||||
const argNode = asNode(arg, `arguments[${index}]`)
|
||||
if (argNode.type === "SpreadElement") {
|
||||
const spread = yield* self.evaluateExpression(getNode(argNode, "argument"))
|
||||
const items = spreadItems(spread)
|
||||
if (items === undefined) throw new InterpreterRuntimeError("Spread arguments require an array, string, Map, or Set in CodeMode.", argNode)
|
||||
if (items === undefined)
|
||||
throw new InterpreterRuntimeError(
|
||||
"Spread arguments require an array, string, Map, or Set in CodeMode.",
|
||||
argNode,
|
||||
)
|
||||
args.push(...items)
|
||||
} else {
|
||||
args.push(yield* self.evaluateExpression(argNode))
|
||||
@@ -2514,13 +2912,19 @@ class Interpreter<R> {
|
||||
// whatever - because tool calls already run eagerly on their own fibers; the combinators
|
||||
// only observe settlements. Joining is therefore sequential (no extra fibers) without
|
||||
// costing parallelism, and the concurrency cap stays where the work is: the fork semaphore.
|
||||
private invokePromiseMethod(ref: PromiseMethodReference, args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
private invokePromiseMethod(
|
||||
ref: PromiseMethodReference,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
const self = this
|
||||
if (ref.name === "resolve") {
|
||||
// Promise.resolve of a promise is that promise (JS flattens); anything else is a
|
||||
// promise already fulfilled with the value.
|
||||
const value = args[0]
|
||||
return Effect.succeed(value instanceof SandboxPromise ? value : new SandboxPromise(undefined, Effect.succeed(value)))
|
||||
return Effect.succeed(
|
||||
value instanceof SandboxPromise ? value : new SandboxPromise(undefined, Effect.succeed(value)),
|
||||
)
|
||||
}
|
||||
if (ref.name === "reject") {
|
||||
return Effect.sync(() => new SandboxPromise(undefined, Effect.fail(new ProgramThrow(args[0]))))
|
||||
@@ -2539,8 +2943,10 @@ class Interpreter<R> {
|
||||
// Mark every promise element observed up-front (Promise.all handles all of its
|
||||
// members' failures, as in JS), then join in index order; the first failure rejects
|
||||
// the whole call while unrelated in-flight members keep running.
|
||||
const settles = items.map((item) => (item instanceof SandboxPromise ? this.settlePromise(item, node) : Effect.succeed(item)))
|
||||
return Effect.gen(function*() {
|
||||
const settles = items.map((item) =>
|
||||
item instanceof SandboxPromise ? this.settlePromise(item, node) : Effect.succeed(item),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
const values: Array<unknown> = []
|
||||
for (const settle of settles) values.push(yield* settle)
|
||||
return values
|
||||
@@ -2550,13 +2956,16 @@ class Interpreter<R> {
|
||||
const observations = items.map((item) =>
|
||||
item instanceof SandboxPromise
|
||||
? Effect.map(this.observePromise(item), (exit) => ({ promise: item as SandboxPromise | undefined, exit }))
|
||||
: Effect.succeed({ promise: undefined as SandboxPromise | undefined, exit: Exit.succeed(item as unknown) }))
|
||||
return Effect.gen(function*() {
|
||||
: Effect.succeed({ promise: undefined as SandboxPromise | undefined, exit: Exit.succeed(item as unknown) }),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
const outcomes: Array<unknown> = []
|
||||
for (const observation of observations) {
|
||||
const { exit, promise } = yield* observation
|
||||
if (Exit.isSuccess(exit)) {
|
||||
outcomes.push(Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }))
|
||||
outcomes.push(
|
||||
Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }),
|
||||
)
|
||||
continue
|
||||
}
|
||||
const raceInterrupted = promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)
|
||||
@@ -2565,22 +2974,34 @@ class Interpreter<R> {
|
||||
return yield* Effect.failCause(exit.cause)
|
||||
}
|
||||
const thrown = raceInterrupted
|
||||
? new InterpreterRuntimeError("This tool call was interrupted because another value settled a Promise.race first.", node)
|
||||
? new InterpreterRuntimeError(
|
||||
"This tool call was interrupted because another value settled a Promise.race first.",
|
||||
node,
|
||||
)
|
||||
: Cause.squash(exit.cause)
|
||||
outcomes.push(Object.assign(Object.create(null) as SafeObject, { status: "rejected", reason: caughtErrorValue(thrown) }))
|
||||
outcomes.push(
|
||||
Object.assign(Object.create(null) as SafeObject, {
|
||||
status: "rejected",
|
||||
reason: caughtErrorValue(thrown),
|
||||
}),
|
||||
)
|
||||
}
|
||||
return outcomes
|
||||
})
|
||||
}
|
||||
case "race": {
|
||||
if (items.length === 0) {
|
||||
throw new InterpreterRuntimeError("Promise.race([]) would never settle; provide at least one promise or value.", node)
|
||||
throw new InterpreterRuntimeError(
|
||||
"Promise.race([]) would never settle; provide at least one promise or value.",
|
||||
node,
|
||||
)
|
||||
}
|
||||
const observations = items.map((item, index) =>
|
||||
item instanceof SandboxPromise
|
||||
? Effect.map(this.observePromise(item), (exit) => ({ index, exit }))
|
||||
: Effect.succeed({ index, exit: Exit.succeed(item as unknown) }))
|
||||
return Effect.gen(function*() {
|
||||
: Effect.succeed({ index, exit: Exit.succeed(item as unknown) }),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
// First settlement (fulfilled OR rejected) wins; the observations never fail, so
|
||||
// racing them yields exactly that. Losing in-flight calls are then interrupted.
|
||||
const winner = yield* Effect.raceAll(observations)
|
||||
@@ -2590,7 +3011,11 @@ class Interpreter<R> {
|
||||
yield* Fiber.interrupt(item.fiber)
|
||||
}
|
||||
const winningItem = items[winner.index]
|
||||
return yield* self.unwrapPromiseExit(winningItem instanceof SandboxPromise ? winningItem : undefined, winner.exit, node)
|
||||
return yield* self.unwrapPromiseExit(
|
||||
winningItem instanceof SandboxPromise ? winningItem : undefined,
|
||||
winner.exit,
|
||||
node,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2601,7 +3026,7 @@ class Interpreter<R> {
|
||||
return Effect.suspend(() => {
|
||||
const savedScopes = self.scopes
|
||||
self.scopes = [...fn.capturedScopes, new Map<string, Binding>()]
|
||||
const run = Effect.gen(function*() {
|
||||
const run = Effect.gen(function* () {
|
||||
// Seed every parameter name into the scope as a TDZ slot first, so a default that
|
||||
// references another parameter resolves to that (uninitialized) param rather than
|
||||
// silently falling through to an outer binding of the same name - matching JS.
|
||||
@@ -2626,11 +3051,21 @@ class Interpreter<R> {
|
||||
|
||||
return yield* self.evaluateExpression(fn.body)
|
||||
})
|
||||
return run.pipe(Effect.ensuring(Effect.sync(() => { self.scopes = savedScopes })))
|
||||
return run.pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
self.scopes = savedScopes
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private invokeIntrinsic(ref: IntrinsicReference, args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
private invokeIntrinsic(
|
||||
ref: IntrinsicReference,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
if (typeof ref.receiver === "string") {
|
||||
return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node))
|
||||
}
|
||||
@@ -2657,7 +3092,11 @@ class Interpreter<R> {
|
||||
|
||||
// Runs a Map/Set callback (forEach) accepting a user function or a builtin coercion,
|
||||
// mirroring the array-method callback contract.
|
||||
private applyCollectionCallback(callback: unknown, name: string, node: AstNode): (args: Array<unknown>) => Effect.Effect<unknown, unknown, R> {
|
||||
private applyCollectionCallback(
|
||||
callback: unknown,
|
||||
name: string,
|
||||
node: AstNode,
|
||||
): (args: Array<unknown>) => Effect.Effect<unknown, unknown, R> {
|
||||
if (!(callback instanceof CodeModeFunction) && !(callback instanceof CoercionFunction)) {
|
||||
throw new InterpreterRuntimeError(`${name} expects a function callback.`, node)
|
||||
}
|
||||
@@ -2667,64 +3106,96 @@ class Interpreter<R> {
|
||||
: this.invokeFunction(callback, callbackArgs)
|
||||
}
|
||||
|
||||
private invokeMapMethod(target: SandboxMap, name: string, args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
private invokeMapMethod(
|
||||
target: SandboxMap,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
switch (name) {
|
||||
case "get": return Effect.succeed(target.map.get(args[0]))
|
||||
case "has": return Effect.succeed(target.map.has(args[0]))
|
||||
case "set": return Effect.sync(() => {
|
||||
target.map.set(args[0], args[1])
|
||||
return target
|
||||
})
|
||||
case "delete": return Effect.sync(() => target.map.delete(args[0]))
|
||||
case "clear": return Effect.sync(() => {
|
||||
target.map.clear()
|
||||
return undefined
|
||||
})
|
||||
case "keys": return Effect.sync(() => Array.from(target.map.keys()))
|
||||
case "values": return Effect.sync(() => Array.from(target.map.values()))
|
||||
case "entries": return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array<unknown> => [key, item]))
|
||||
case "get":
|
||||
return Effect.succeed(target.map.get(args[0]))
|
||||
case "has":
|
||||
return Effect.succeed(target.map.has(args[0]))
|
||||
case "set":
|
||||
return Effect.sync(() => {
|
||||
target.map.set(args[0], args[1])
|
||||
return target
|
||||
})
|
||||
case "delete":
|
||||
return Effect.sync(() => target.map.delete(args[0]))
|
||||
case "clear":
|
||||
return Effect.sync(() => {
|
||||
target.map.clear()
|
||||
return undefined
|
||||
})
|
||||
case "keys":
|
||||
return Effect.sync(() => Array.from(target.map.keys()))
|
||||
case "values":
|
||||
return Effect.sync(() => Array.from(target.map.values()))
|
||||
case "entries":
|
||||
return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array<unknown> => [key, item]))
|
||||
case "forEach": {
|
||||
const apply = this.applyCollectionCallback(args[0], "Map.forEach", node)
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
// Snapshot iteration, matching the array-method callback contract.
|
||||
for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target])
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
default: throw new InterpreterRuntimeError(`Map method '${name}' is not available in CodeMode.`, node)
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Map method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
private invokeSetMethod(target: SandboxSet, name: string, args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
private invokeSetMethod(
|
||||
target: SandboxSet,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
switch (name) {
|
||||
case "has": return Effect.succeed(target.set.has(args[0]))
|
||||
case "add": return Effect.sync(() => {
|
||||
target.set.add(args[0])
|
||||
return target
|
||||
})
|
||||
case "delete": return Effect.sync(() => target.set.delete(args[0]))
|
||||
case "clear": return Effect.sync(() => {
|
||||
target.set.clear()
|
||||
return undefined
|
||||
})
|
||||
case "has":
|
||||
return Effect.succeed(target.set.has(args[0]))
|
||||
case "add":
|
||||
return Effect.sync(() => {
|
||||
target.set.add(args[0])
|
||||
return target
|
||||
})
|
||||
case "delete":
|
||||
return Effect.sync(() => target.set.delete(args[0]))
|
||||
case "clear":
|
||||
return Effect.sync(() => {
|
||||
target.set.clear()
|
||||
return undefined
|
||||
})
|
||||
case "keys":
|
||||
case "values": return Effect.sync(() => Array.from(target.set.values()))
|
||||
case "entries": return Effect.sync(() => Array.from(target.set.values(), (item): Array<unknown> => [item, item]))
|
||||
case "values":
|
||||
return Effect.sync(() => Array.from(target.set.values()))
|
||||
case "entries":
|
||||
return Effect.sync(() => Array.from(target.set.values(), (item): Array<unknown> => [item, item]))
|
||||
case "forEach": {
|
||||
const apply = this.applyCollectionCallback(args[0], "Set.forEach", node)
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
for (const item of Array.from(target.set.values())) yield* apply([item, item, target])
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
default: throw new InterpreterRuntimeError(`Set method '${name}' is not available in CodeMode.`, node)
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Set method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
private invokeArrayMethod(target: Array<unknown>, name: string, args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
private invokeArrayMethod(
|
||||
target: Array<unknown>,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
const optNumber = (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)
|
||||
if (typeof value !== "number")
|
||||
throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node)
|
||||
return value
|
||||
}
|
||||
switch (name) {
|
||||
@@ -2733,15 +3204,22 @@ class Interpreter<R> {
|
||||
throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node)
|
||||
}
|
||||
const input = boundedData(target, "Array.join input") as Array<unknown>
|
||||
return Effect.succeed(input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : args[0] as string))
|
||||
return Effect.succeed(
|
||||
input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)),
|
||||
)
|
||||
}
|
||||
case "includes":
|
||||
if (args.length === 0 || args.length > 2) throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node)
|
||||
if (args.length === 0 || args.length > 2)
|
||||
throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node)
|
||||
return Effect.succeed(target.includes(args[0], optNumber(args[1], "start index")))
|
||||
case "indexOf":
|
||||
return Effect.succeed(target.indexOf(args[0], optNumber(args[1], "start index")))
|
||||
case "lastIndexOf":
|
||||
return Effect.succeed(args[1] === undefined ? target.lastIndexOf(args[0]) : target.lastIndexOf(args[0], optNumber(args[1], "start index")))
|
||||
return Effect.succeed(
|
||||
args[1] === undefined
|
||||
? target.lastIndexOf(args[0])
|
||||
: target.lastIndexOf(args[0], optNumber(args[1], "start index")),
|
||||
)
|
||||
case "at":
|
||||
return Effect.succeed(target.at(optNumber(args[0], "index") ?? 0))
|
||||
case "slice":
|
||||
@@ -2799,7 +3277,13 @@ class Interpreter<R> {
|
||||
return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end")))
|
||||
}
|
||||
case "copyWithin":
|
||||
return Effect.succeed(target.copyWithin(optNumber(args[0], "target index") ?? 0, optNumber(args[1], "start") ?? 0, optNumber(args[2], "end")))
|
||||
return Effect.succeed(
|
||||
target.copyWithin(
|
||||
optNumber(args[0], "target index") ?? 0,
|
||||
optNumber(args[1], "start") ?? 0,
|
||||
optNumber(args[2], "end"),
|
||||
),
|
||||
)
|
||||
// keys/values/entries return arrays (not iterators), matching the Map/Set convention;
|
||||
// they work with for...of and spread either way.
|
||||
case "keys":
|
||||
@@ -2822,7 +3306,7 @@ class Interpreter<R> {
|
||||
callback instanceof CoercionFunction
|
||||
? Effect.succeed(invokeCoercion(callback, callbackArgs, node))
|
||||
: self.invokeFunction(callback, callbackArgs)
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
// Iterate a snapshot taken at call time so a callback that mutates the array can't
|
||||
// self-extend the loop - matching JS, where elements appended during iteration are not visited.
|
||||
const items = target.slice()
|
||||
@@ -2878,7 +3362,8 @@ class Interpreter<R> {
|
||||
accumulator = args[1]
|
||||
start = 0
|
||||
} else {
|
||||
if (items.length === 0) throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node)
|
||||
if (items.length === 0)
|
||||
throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node)
|
||||
accumulator = items[0]
|
||||
start = 1
|
||||
}
|
||||
@@ -2894,7 +3379,8 @@ class Interpreter<R> {
|
||||
accumulator = args[1]
|
||||
start = items.length - 1
|
||||
} else {
|
||||
if (items.length === 0) throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node)
|
||||
if (items.length === 0)
|
||||
throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node)
|
||||
accumulator = items[items.length - 1]
|
||||
start = items.length - 2
|
||||
}
|
||||
@@ -2918,7 +3404,11 @@ class Interpreter<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private sortArray(target: Array<unknown>, comparator: unknown, node: AstNode): Effect.Effect<Array<unknown>, unknown, R> {
|
||||
private sortArray(
|
||||
target: Array<unknown>,
|
||||
comparator: unknown,
|
||||
node: AstNode,
|
||||
): Effect.Effect<Array<unknown>, unknown, R> {
|
||||
if (comparator !== undefined && !(comparator instanceof CodeModeFunction)) {
|
||||
throw new InterpreterRuntimeError("Array.sort expects an arrow function comparator.", node)
|
||||
}
|
||||
@@ -2928,13 +3418,14 @@ class Interpreter<R> {
|
||||
const left = coerceToString(a)
|
||||
const right = coerceToString(b)
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
const self = this
|
||||
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)
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
const left = yield* mergeSort(items.slice(0, midpoint))
|
||||
const right = yield* mergeSort(items.slice(midpoint))
|
||||
const merged: Array<unknown> = []
|
||||
@@ -2960,7 +3451,7 @@ class Interpreter<R> {
|
||||
const objectValue: Record<string, unknown> = Object.create(null) as Record<string, unknown>
|
||||
const properties = getArray(node, "properties")
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
for (const propertyValue of properties) {
|
||||
const property = asNode(propertyValue, "properties")
|
||||
|
||||
@@ -2971,10 +3462,15 @@ class Interpreter<R> {
|
||||
// (Date/RegExp/Map/Set) have no own enumerable properties in JS, so they are no-ops too.
|
||||
if (spread === null || spread === undefined || isSandboxValue(spread)) continue
|
||||
if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
|
||||
throw new InterpreterRuntimeError("Object spread requires a data object in CodeMode.", property, "InvalidDataValue")
|
||||
throw new InterpreterRuntimeError(
|
||||
"Object spread requires a data object in CodeMode.",
|
||||
property,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
for (const [key, value] of Object.entries(spread)) {
|
||||
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, property)
|
||||
if (isBlockedMember(key))
|
||||
throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, property)
|
||||
objectValue[key] = value
|
||||
}
|
||||
continue
|
||||
@@ -3019,7 +3515,7 @@ class Interpreter<R> {
|
||||
const values: Array<unknown> = []
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
for (const elementValue of elements) {
|
||||
if (elementValue === null) {
|
||||
values.push(undefined)
|
||||
@@ -3029,7 +3525,11 @@ class Interpreter<R> {
|
||||
if (element.type === "SpreadElement") {
|
||||
const spread = yield* self.evaluateExpression(getNode(element, "argument"))
|
||||
const items = spreadItems(spread)
|
||||
if (items === undefined) throw new InterpreterRuntimeError("Array spread requires an array, string, Map, or Set in CodeMode.", element)
|
||||
if (items === undefined)
|
||||
throw new InterpreterRuntimeError(
|
||||
"Array spread requires an array, string, Map, or Set in CodeMode.",
|
||||
element,
|
||||
)
|
||||
values.push(...items)
|
||||
} else {
|
||||
values.push(yield* self.evaluateExpression(element))
|
||||
@@ -3046,7 +3546,7 @@ class Interpreter<R> {
|
||||
let output = ""
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
for (let index = 0; index < quasis.length; index += 1) {
|
||||
const quasi = asNode(quasis[index], "quasis")
|
||||
const rawValue = quasi.value
|
||||
@@ -3071,15 +3571,11 @@ class Interpreter<R> {
|
||||
|
||||
private evaluateConditionalExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
return Effect.flatMap(this.evaluateExpression(getNode(node, "test")), (test) =>
|
||||
this.evaluateExpression(getNode(node, test ? "consequent" : "alternate")))
|
||||
this.evaluateExpression(getNode(node, test ? "consequent" : "alternate")),
|
||||
)
|
||||
}
|
||||
|
||||
private applyCompoundAssignment(
|
||||
operator: string,
|
||||
current: unknown,
|
||||
incoming: unknown,
|
||||
node: AstNode,
|
||||
): unknown {
|
||||
private applyCompoundAssignment(operator: string, current: unknown, incoming: unknown, node: AstNode): unknown {
|
||||
// `x op= y` is `x = x op y`: dispatch through the shared binary operator implementation
|
||||
// so compound assignment inherits the same coercion semantics (Dates, data objects, ...).
|
||||
// Only the arithmetic/bitwise operators are compoundable; logical assignments (&&=/||=/??=)
|
||||
@@ -3090,13 +3586,26 @@ class Interpreter<R> {
|
||||
return this.applyBinaryOperator(operator.slice(0, -1), current, incoming, node)
|
||||
}
|
||||
|
||||
private getMemberReference(node: AstNode): Effect.Effect<MemberReference | ToolReference | PromiseMethodReference | IntrinsicReference | GlobalMethodReference | ComputedValue | typeof OptionalShortCircuit | undefined, unknown, R> {
|
||||
private getMemberReference(
|
||||
node: AstNode,
|
||||
): Effect.Effect<
|
||||
| MemberReference
|
||||
| ToolReference
|
||||
| PromiseMethodReference
|
||||
| IntrinsicReference
|
||||
| GlobalMethodReference
|
||||
| ComputedValue
|
||||
| typeof OptionalShortCircuit
|
||||
| undefined,
|
||||
unknown,
|
||||
R
|
||||
> {
|
||||
const objectNode = getNode(node, "object")
|
||||
const propertyNode = getNode(node, "property")
|
||||
const computed = getBoolean(node, "computed")
|
||||
const optional = node.optional === true
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
const objectValue = yield* self.evaluateExpression(objectNode)
|
||||
if (objectValue === OptionalShortCircuit) return OptionalShortCircuit
|
||||
if ((objectValue === null || objectValue === undefined) && optional) return OptionalShortCircuit
|
||||
@@ -3126,7 +3635,10 @@ class Interpreter<R> {
|
||||
|
||||
if (objectValue instanceof GlobalNamespace) {
|
||||
if (typeof key !== "string" || isBlockedMember(key)) {
|
||||
throw new InterpreterRuntimeError(`${objectValue.name}.${String(key)} is not available in CodeMode.`, propertyNode)
|
||||
throw new InterpreterRuntimeError(
|
||||
`${objectValue.name}.${String(key)} is not available in CodeMode.`,
|
||||
propertyNode,
|
||||
)
|
||||
}
|
||||
if (objectValue.name === "Math" && mathConstants.has(key)) {
|
||||
return new ComputedValue((Math as unknown as Record<string, number>)[key])
|
||||
@@ -3205,7 +3717,11 @@ class Interpreter<R> {
|
||||
}
|
||||
|
||||
if (isRuntimeReference(objectValue)) {
|
||||
throw new InterpreterRuntimeError("CodeMode runtime references are opaque and do not expose properties.", objectNode, "InvalidDataValue")
|
||||
throw new InterpreterRuntimeError(
|
||||
"CodeMode runtime references are opaque and do not expose properties.",
|
||||
objectNode,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof objectValue !== "object" || objectValue === null) {
|
||||
@@ -3217,7 +3733,12 @@ class Interpreter<R> {
|
||||
}
|
||||
|
||||
if (Array.isArray(objectValue)) {
|
||||
if (key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && (typeof key !== "number" && !/^\d+$/.test(key))) {
|
||||
if (
|
||||
key !== "length" &&
|
||||
!(typeof key === "string" && arrayMethods.has(key)) &&
|
||||
typeof key !== "number" &&
|
||||
!/^\d+$/.test(key)
|
||||
) {
|
||||
// Own non-index properties read through (match results carry index/groups); like JS,
|
||||
// they are readable in place and dropped by JSON at data boundaries.
|
||||
if (typeof key === "string" && Object.hasOwn(objectValue, key)) {
|
||||
@@ -3244,7 +3765,8 @@ class Interpreter<R> {
|
||||
reference instanceof PromiseMethodReference ||
|
||||
reference instanceof IntrinsicReference ||
|
||||
reference instanceof GlobalMethodReference
|
||||
) return reference
|
||||
)
|
||||
return reference
|
||||
if (Array.isArray(reference.target)) {
|
||||
if (typeof reference.key === "string" && arrayMethods.has(reference.key)) {
|
||||
return new IntrinsicReference(reference.target, reference.key)
|
||||
@@ -3267,7 +3789,7 @@ class Interpreter<R> {
|
||||
compute: (current: unknown) => Effect.Effect<{ write: boolean; next: unknown; result: unknown }, unknown, R>,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
return Effect.gen(function* () {
|
||||
const reference = yield* self.getMemberReference(node)
|
||||
if (
|
||||
reference === OptionalShortCircuit ||
|
||||
@@ -3281,7 +3803,8 @@ class Interpreter<R> {
|
||||
throw new InterpreterRuntimeError("Only data fields may be assigned in CodeMode.", node)
|
||||
}
|
||||
if (Array.isArray(reference.target)) {
|
||||
if (reference.key === "length") throw new InterpreterRuntimeError("Array length cannot be assigned in CodeMode.", node)
|
||||
if (reference.key === "length")
|
||||
throw new InterpreterRuntimeError("Array length cannot be assigned in CodeMode.", node)
|
||||
if (typeof reference.key === "string" && arrayMethods.has(reference.key)) {
|
||||
throw new InterpreterRuntimeError("Array methods cannot be assigned in CodeMode.", node)
|
||||
}
|
||||
@@ -3296,8 +3819,15 @@ class Interpreter<R> {
|
||||
|
||||
// Rejects inserting a value that (transitively) contains the container it is being inserted
|
||||
// into - the mutation that would create a circular structure no later walk could survive.
|
||||
private rejectCircularInsertion(container: object, value: unknown, label: string, node: AstNode, seen = new Set<object>()): void {
|
||||
if (value === container) throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
|
||||
private rejectCircularInsertion(
|
||||
container: object,
|
||||
value: unknown,
|
||||
label: string,
|
||||
node: AstNode,
|
||||
seen = new Set<object>(),
|
||||
): void {
|
||||
if (value === container)
|
||||
throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
|
||||
if (value === null || typeof value !== "object" || isRuntimeReference(value) || seen.has(value)) return
|
||||
seen.add(value)
|
||||
const items = Array.isArray(value) ? value : Object.values(value)
|
||||
@@ -3310,7 +3840,11 @@ class Interpreter<R> {
|
||||
const target = reference.target
|
||||
const index = key as number
|
||||
if (!Number.isInteger(index) || index < 0) {
|
||||
throw new InterpreterRuntimeError("Array assignment index must be a non-negative integer.", node, "InvalidDataValue")
|
||||
throw new InterpreterRuntimeError(
|
||||
"Array assignment index must be a non-negative integer.",
|
||||
node,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
this.rejectCircularInsertion(target, next, "Array assignment result", node)
|
||||
target[index] = next
|
||||
@@ -3403,7 +3937,6 @@ class Interpreter<R> {
|
||||
private popScope(): void {
|
||||
this.scopes.pop()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3426,9 +3959,14 @@ const executeWithLimits = <const Tools extends Record<string, unknown>>(
|
||||
...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }),
|
||||
...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }),
|
||||
}
|
||||
const tools = ToolRuntime.make((options.tools ?? {}) as HostTools<Services<Tools>>, limits.maxToolCalls, hooks, searchIndex)
|
||||
const tools = ToolRuntime.make(
|
||||
(options.tools ?? {}) as HostTools<Services<Tools>>,
|
||||
limits.maxToolCalls,
|
||||
hooks,
|
||||
searchIndex,
|
||||
)
|
||||
const logs: Array<string> = []
|
||||
const logged = () => logs.length > 0 ? { logs: [...logs] } : {}
|
||||
const logged = () => (logs.length > 0 ? { logs: [...logs] } : {})
|
||||
|
||||
if (options.code.trim().length === 0) {
|
||||
return Effect.succeed({
|
||||
@@ -3438,7 +3976,7 @@ const executeWithLimits = <const Tools extends Record<string, unknown>>(
|
||||
})
|
||||
}
|
||||
|
||||
const operation = Effect.gen(function*() {
|
||||
const operation = Effect.gen(function* () {
|
||||
const program = parseProgram(options.code)
|
||||
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.keys, logs)
|
||||
const value = yield* interpreter.run(program)
|
||||
@@ -3449,23 +3987,22 @@ const executeWithLimits = <const Tools extends Record<string, unknown>>(
|
||||
...logged(),
|
||||
toolCalls: tools.calls,
|
||||
} satisfies ExecuteResult
|
||||
}).pipe(
|
||||
(program) => {
|
||||
const timeoutMs = limits.timeoutMs
|
||||
if (timeoutMs === undefined) return program
|
||||
return program.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: timeoutMs,
|
||||
orElse: () => Effect.succeed({
|
||||
}).pipe((program) => {
|
||||
const timeoutMs = limits.timeoutMs
|
||||
if (timeoutMs === undefined) return program
|
||||
return program.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: timeoutMs,
|
||||
orElse: () =>
|
||||
Effect.succeed({
|
||||
ok: false,
|
||||
error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` },
|
||||
...logged(),
|
||||
toolCalls: tools.calls,
|
||||
} satisfies ExecuteResult),
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return operation.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
@@ -3478,7 +4015,7 @@ const executeWithLimits = <const Tools extends Record<string, unknown>>(
|
||||
toolCalls: tools.calls,
|
||||
} satisfies ExecuteResult),
|
||||
),
|
||||
Effect.map((result) => limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes)),
|
||||
Effect.map((result) => (limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes))),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3540,7 +4077,9 @@ const boundOutput = (result: ExecuteResult, maxOutputBytes: number): ExecuteResu
|
||||
: { ok: false, error: result.error, ...logsPart, truncated: true, toolCalls: result.toolCalls }
|
||||
}
|
||||
|
||||
export const execute = <const Tools extends Record<string, unknown>>(options: ExecuteOptions<Tools>): Effect.Effect<ExecuteResult, never, Services<Tools>> => {
|
||||
export const execute = <const Tools extends Record<string, unknown>>(
|
||||
options: ExecuteOptions<Tools>,
|
||||
): Effect.Effect<ExecuteResult, never, Services<Tools>> => {
|
||||
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
|
||||
ToolRuntime.assertValidTools(tools)
|
||||
return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools))
|
||||
@@ -3558,7 +4097,9 @@ export const execute = <const Tools extends Record<string, unknown>>(options: Ex
|
||||
* const code = runtime.agentTool()
|
||||
* ```
|
||||
*/
|
||||
export const make = <const Tools extends Record<string, unknown> = {}>(options: CodeModeOptions<Tools> = {} as CodeModeOptions<Tools>): CodeModeRuntime<Services<Tools>> => {
|
||||
export const make = <const Tools extends Record<string, unknown> = {}>(
|
||||
options: CodeModeOptions<Tools> = {} as CodeModeOptions<Tools>,
|
||||
): CodeModeRuntime<Services<Tools>> => {
|
||||
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
|
||||
ToolRuntime.assertValidTools(tools)
|
||||
const limits = resolveExecutionLimits(options.limits)
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
export {
|
||||
ToolError,
|
||||
CodeMode,
|
||||
ExecuteInputSchema,
|
||||
ExecuteResultSchema,
|
||||
toolError,
|
||||
} from "./codemode.js"
|
||||
export { ToolError, CodeMode, ExecuteInputSchema, ExecuteResultSchema, toolError } from "./codemode.js"
|
||||
export { Tool } from "./tool.js"
|
||||
export type { Definition as ToolDefinition, JsonSchema, ToolSchema } from "./tool.js"
|
||||
export type { ToolCallEnded, ToolCallHooks } from "./tool-runtime.js"
|
||||
|
||||
@@ -21,11 +21,16 @@ export type HostTools<R = never> = {
|
||||
|
||||
export type Services<Tools> = Tools extends (...args: Array<unknown>) => Effect.Effect<unknown, unknown, infer R>
|
||||
? R
|
||||
: Tools extends { readonly _tag: "CodeModeTool"; readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R> }
|
||||
: Tools extends {
|
||||
readonly _tag: "CodeModeTool"
|
||||
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
|
||||
}
|
||||
? R
|
||||
: Tools extends object
|
||||
? string extends keyof Tools ? never : Services<Tools[keyof Tools]>
|
||||
: never
|
||||
: Tools extends object
|
||||
? string extends keyof Tools
|
||||
? never
|
||||
: Services<Tools[keyof Tools]>
|
||||
: never
|
||||
|
||||
/** Minimal audit record retained for each admitted tool call. */
|
||||
export type ToolCall = {
|
||||
@@ -68,11 +73,13 @@ export type SafeObject = Record<string, unknown>
|
||||
const reservedNamespace = "$codemode"
|
||||
const defaultMaxInlineCatalogTokens = 2_000
|
||||
const defaultSearchLimit = 10
|
||||
const searchSignature = "tools.$codemode.search({ query?: string, namespace?: string, limit?: number }): Promise<{ items: Array<{ path: string; description: string; signature: string }>; total: number }>"
|
||||
const searchSignature =
|
||||
"tools.$codemode.search({ query?: string, namespace?: string, limit?: number }): Promise<{ items: Array<{ path: string; description: string; signature: string }>; total: number }>"
|
||||
const toolExpression = (path: string) =>
|
||||
"tools" + path
|
||||
"tools" +
|
||||
path
|
||||
.split(".")
|
||||
.map((segment) => identifierSegment.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`)
|
||||
.map((segment) => (identifierSegment.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`))
|
||||
.join("")
|
||||
|
||||
export class ToolReference {
|
||||
@@ -88,7 +95,12 @@ const MAX_VALUE_DEPTH = 32
|
||||
|
||||
export class ToolRuntimeError extends Error {
|
||||
constructor(
|
||||
readonly kind: "UnknownTool" | "InvalidToolInput" | "InvalidToolOutput" | "InvalidDataValue" | "ToolCallLimitExceeded",
|
||||
readonly kind:
|
||||
| "UnknownTool"
|
||||
| "InvalidToolInput"
|
||||
| "InvalidToolOutput"
|
||||
| "InvalidDataValue"
|
||||
| "ToolCallLimitExceeded",
|
||||
message: string,
|
||||
readonly suggestions: ReadonlyArray<string> = [],
|
||||
) {
|
||||
@@ -131,7 +143,13 @@ export const isBlockedMember = (name: string): boolean => blockedMemberNames.has
|
||||
export const copyIn = (value: unknown, label: string, preserveSandboxValues = false): unknown =>
|
||||
copyBounded(value, label, 0, new Set(), preserveSandboxValues)
|
||||
|
||||
const copyBounded = (value: unknown, label: string, depth: number, seen: Set<object>, preserveSandboxValues: boolean): unknown => {
|
||||
const copyBounded = (
|
||||
value: unknown,
|
||||
label: string,
|
||||
depth: number,
|
||||
seen: Set<object>,
|
||||
preserveSandboxValues: boolean,
|
||||
): unknown => {
|
||||
if (depth > MAX_VALUE_DEPTH) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
|
||||
}
|
||||
@@ -166,7 +184,12 @@ const copyBounded = (value: unknown, label: string, depth: number, seen: Set<obj
|
||||
// Intra-sandbox checkpoints keep sandbox value instances alive as leaves; their contents
|
||||
// are never walked here (Map/Set members are validated where mutation happens, and the
|
||||
// real boundary still serializes them below).
|
||||
if (value instanceof SandboxDate || value instanceof SandboxRegExp || value instanceof SandboxMap || value instanceof SandboxSet) {
|
||||
if (
|
||||
value instanceof SandboxDate ||
|
||||
value instanceof SandboxRegExp ||
|
||||
value instanceof SandboxMap ||
|
||||
value instanceof SandboxSet
|
||||
) {
|
||||
return value
|
||||
}
|
||||
// Host instances cannot normally reach an intra-sandbox checkpoint (tool results cross
|
||||
@@ -197,8 +220,12 @@ const copyBounded = (value: unknown, label: string, depth: number, seen: Set<obj
|
||||
return Number.isFinite(value.getTime()) ? value.toISOString() : null
|
||||
}
|
||||
if (
|
||||
value instanceof SandboxRegExp || value instanceof SandboxMap || value instanceof SandboxSet ||
|
||||
value instanceof RegExp || value instanceof Map || value instanceof Set
|
||||
value instanceof SandboxRegExp ||
|
||||
value instanceof SandboxMap ||
|
||||
value instanceof SandboxSet ||
|
||||
value instanceof RegExp ||
|
||||
value instanceof Map ||
|
||||
value instanceof Set
|
||||
) {
|
||||
return Object.create(null) as SafeObject
|
||||
}
|
||||
@@ -250,7 +277,10 @@ export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
|
||||
return value
|
||||
}
|
||||
|
||||
const definitions = <R>(tools: HostTools<R>, path: ReadonlyArray<string> = []): Array<{ path: string; definition: Definition<R> }> => {
|
||||
const definitions = <R>(
|
||||
tools: HostTools<R>,
|
||||
path: ReadonlyArray<string> = [],
|
||||
): Array<{ path: string; definition: Definition<R> }> => {
|
||||
const entries: Array<{ path: string; definition: Definition<R> }> = []
|
||||
for (const [name, value] of Object.entries(tools)) {
|
||||
const next = [...path, name]
|
||||
@@ -342,8 +372,11 @@ const toSearchEntry = <R>(path: string, definition: Definition<R>, description:
|
||||
path,
|
||||
definition.description,
|
||||
...inputProperties(definition).flatMap(({ name, description: property }) =>
|
||||
property === undefined ? [name] : [name, property]),
|
||||
].join("\n").toLowerCase(),
|
||||
property === undefined ? [name] : [name, property],
|
||||
),
|
||||
]
|
||||
.join("\n")
|
||||
.toLowerCase(),
|
||||
})
|
||||
|
||||
/** The runtime search index over every described tool. Search is always registered. */
|
||||
@@ -396,7 +429,8 @@ export const discoveryPlan = <R>(
|
||||
namespace,
|
||||
picked: new Set<ToolDescription>(),
|
||||
queue: [...group].sort(
|
||||
(left, right) => estimate(catalogLine(left)) - estimate(catalogLine(right)) || left.path.localeCompare(right.path),
|
||||
(left, right) =>
|
||||
estimate(catalogLine(left)) - estimate(catalogLine(right)) || left.path.localeCompare(right.path),
|
||||
),
|
||||
}))
|
||||
let used = 0
|
||||
@@ -472,7 +506,9 @@ export const discoveryPlan = <R>(
|
||||
"- A result typed `Promise<unknown>` has no guaranteed shape - verify what actually came back before relying on its fields.",
|
||||
"- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`.",
|
||||
"- `Object.keys(tools)` lists namespaces; `Object.keys(tools.<namespace>)` lists its tools; `for...in` works on both.",
|
||||
...(complete ? [] : ['- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.']),
|
||||
...(complete
|
||||
? []
|
||||
: ['- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.']),
|
||||
]
|
||||
|
||||
const syntax = [
|
||||
@@ -500,26 +536,21 @@ export const discoveryPlan = <R>(
|
||||
const count = `${group.length} tool${group.length === 1 ? "" : "s"}`
|
||||
// Annotate only when a namespace is not fully shown, so a comprehensive
|
||||
// namespace reads cleanly and a truncated one is unambiguous.
|
||||
const label = picked.size === group.length ? count : picked.size === 0 ? `${count}, none shown` : `${count}, ${picked.size} shown`
|
||||
const label =
|
||||
picked.size === group.length
|
||||
? count
|
||||
: picked.size === 0
|
||||
? `${count}, none shown`
|
||||
: `${count}, ${picked.size} shown`
|
||||
toolSection.push(`- ${namespace} (${label})`)
|
||||
for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool))
|
||||
}
|
||||
if (!complete) {
|
||||
toolSection.push(
|
||||
"",
|
||||
"Search returns complete callable signatures:",
|
||||
`- ${searchSignature}`,
|
||||
)
|
||||
toolSection.push("", "Search returns complete callable signatures:", `- ${searchSignature}`)
|
||||
}
|
||||
}
|
||||
|
||||
const lines = [
|
||||
...intro,
|
||||
...workflow,
|
||||
...rules,
|
||||
...syntax,
|
||||
...toolSection,
|
||||
]
|
||||
const lines = [...intro, ...workflow, ...rules, ...syntax, ...toolSection]
|
||||
return {
|
||||
catalog: described,
|
||||
instructions: lines.join("\n"),
|
||||
@@ -534,18 +565,29 @@ export const discoveryPlan = <R>(
|
||||
* function in JS). An unknown path is an `UnknownTool` error pointing at the working
|
||||
* discovery idioms, mirroring how calling an unknown tool fails.
|
||||
*/
|
||||
const namespaceKeys = <R>(tools: HostTools<R>, path: ReadonlyArray<string>, searchEnabled: boolean): ReadonlyArray<string> => {
|
||||
const namespaceKeys = <R>(
|
||||
tools: HostTools<R>,
|
||||
path: ReadonlyArray<string>,
|
||||
searchEnabled: boolean,
|
||||
): ReadonlyArray<string> => {
|
||||
// The reserved discovery namespace is virtual (never present in the host tree); enumerate
|
||||
// it explicitly so `Object.keys(tools.$codemode)` matches the callable surface.
|
||||
if (searchEnabled && path.length === 1 && path[0] === reservedNamespace) return ["search"]
|
||||
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
|
||||
for (const segment of path) {
|
||||
if (isBlockedMember(segment) || typeof value === "function" || isDefinition(value) || !Object.hasOwn(value, segment)) {
|
||||
if (
|
||||
isBlockedMember(segment) ||
|
||||
typeof value === "function" ||
|
||||
isDefinition(value) ||
|
||||
!Object.hasOwn(value, segment)
|
||||
) {
|
||||
throw new ToolRuntimeError(
|
||||
"UnknownTool",
|
||||
`Unknown tool namespace '${path.join(".")}'.`,
|
||||
searchEnabled
|
||||
? ["Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools."]
|
||||
? [
|
||||
"Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools.",
|
||||
]
|
||||
: ["Object.keys(tools) lists the available namespaces."],
|
||||
)
|
||||
}
|
||||
@@ -555,12 +597,25 @@ const namespaceKeys = <R>(tools: HostTools<R>, path: ReadonlyArray<string>, sear
|
||||
return Object.keys(value)
|
||||
}
|
||||
|
||||
const resolve = <R>(tools: HostTools<R>, path: ReadonlyArray<string>, searchEnabled: boolean): HostTool<R> | Definition<R> => {
|
||||
const resolve = <R>(
|
||||
tools: HostTools<R>,
|
||||
path: ReadonlyArray<string>,
|
||||
searchEnabled: boolean,
|
||||
): HostTool<R> | Definition<R> => {
|
||||
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
|
||||
|
||||
for (const segment of path) {
|
||||
if (isBlockedMember(segment) || typeof value === "function" || isDefinition(value) || !Object.hasOwn(value, segment)) {
|
||||
throw new ToolRuntimeError("UnknownTool", `Unknown tool '${path.join(".")}'.`, searchEnabled ? ["Use tools.$codemode.search({ query }) to find available described tools."] : [])
|
||||
if (
|
||||
isBlockedMember(segment) ||
|
||||
typeof value === "function" ||
|
||||
isDefinition(value) ||
|
||||
!Object.hasOwn(value, segment)
|
||||
) {
|
||||
throw new ToolRuntimeError(
|
||||
"UnknownTool",
|
||||
`Unknown tool '${path.join(".")}'.`,
|
||||
searchEnabled ? ["Use tools.$codemode.search({ query }) to find available described tools."] : [],
|
||||
)
|
||||
}
|
||||
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
|
||||
}
|
||||
@@ -602,7 +657,8 @@ export const make = <R>(
|
||||
return effect.pipe(
|
||||
Effect.tap(() => onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "success" })),
|
||||
Effect.tapError((error) =>
|
||||
onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "failure", message: failureMessage(error) })),
|
||||
onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "failure", message: failureMessage(error) }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -624,7 +680,7 @@ export const make = <R>(
|
||||
calls,
|
||||
keys: (path) => namespaceKeys(tools, path, searchEnabled),
|
||||
invoke: (path, args) =>
|
||||
Effect.gen(function*() {
|
||||
Effect.gen(function* () {
|
||||
const name = path.join(".")
|
||||
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`)))
|
||||
const call = { name }
|
||||
@@ -637,17 +693,32 @@ export const make = <R>(
|
||||
if (!searchEnabled) throw new ToolRuntimeError("UnknownTool", `Unknown tool '${name}'.`)
|
||||
const input = externalArgs[0]
|
||||
if (externalArgs.length !== 1 || input === null || typeof input !== "object" || Array.isArray(input)) {
|
||||
throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search expects { query?: string; namespace?: string; limit?: number }.")
|
||||
throw new ToolRuntimeError(
|
||||
"InvalidToolInput",
|
||||
"tools.$codemode.search expects { query?: string; namespace?: string; limit?: number }.",
|
||||
)
|
||||
}
|
||||
const request = input as { query?: unknown; namespace?: unknown; limit?: unknown }
|
||||
if (request.query !== undefined && typeof request.query !== "string") {
|
||||
throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search query must be a string when provided.")
|
||||
throw new ToolRuntimeError(
|
||||
"InvalidToolInput",
|
||||
"tools.$codemode.search query must be a string when provided.",
|
||||
)
|
||||
}
|
||||
if (request.namespace !== undefined && typeof request.namespace !== "string") {
|
||||
throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search namespace must be a string when provided.")
|
||||
throw new ToolRuntimeError(
|
||||
"InvalidToolInput",
|
||||
"tools.$codemode.search namespace must be a string when provided.",
|
||||
)
|
||||
}
|
||||
if (request.limit !== undefined && (typeof request.limit !== "number" || !Number.isSafeInteger(request.limit) || request.limit <= 0)) {
|
||||
throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search limit must be a positive safe integer when provided.")
|
||||
if (
|
||||
request.limit !== undefined &&
|
||||
(typeof request.limit !== "number" || !Number.isSafeInteger(request.limit) || request.limit <= 0)
|
||||
) {
|
||||
throw new ToolRuntimeError(
|
||||
"InvalidToolInput",
|
||||
"tools.$codemode.search limit must be a positive safe integer when provided.",
|
||||
)
|
||||
}
|
||||
const query = typeof request.query === "string" ? request.query : ""
|
||||
const namespace = typeof request.namespace === "string" ? request.namespace : undefined
|
||||
@@ -656,40 +727,50 @@ export const make = <R>(
|
||||
Effect.try({
|
||||
try: () => {
|
||||
const limit = typeof request.limit === "number" ? request.limit : defaultSearchLimit
|
||||
const scoped = namespace === undefined ? searchIndex : searchIndex.filter((entry) => entry.namespace === namespace)
|
||||
const scoped =
|
||||
namespace === undefined ? searchIndex : searchIndex.filter((entry) => entry.namespace === namespace)
|
||||
// A query that names one tool path exactly (canonical path or rendered
|
||||
// JavaScript expression) is a lookup, not a search: return that tool alone.
|
||||
const trimmed = query.trim()
|
||||
const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed
|
||||
const exact = pathQuery === "" ? undefined : scoped.find((entry) =>
|
||||
entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed)
|
||||
const exact =
|
||||
pathQuery === ""
|
||||
? undefined
|
||||
: scoped.find(
|
||||
(entry) =>
|
||||
entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed,
|
||||
)
|
||||
const terms = tokenize(query).map(termForms)
|
||||
// Additive field-weighted scoring, summed across terms: exact path or path
|
||||
// segment (20) > path substring (8) > description substring (4) > any
|
||||
// searchable text, incl. input parameter names/descriptions (2). Each term
|
||||
// matches a field when any of its forms (the term or a singular variant)
|
||||
// does. An empty query browses everything, alphabetical by path.
|
||||
const ranked = exact !== undefined
|
||||
? [exact]
|
||||
: scoped
|
||||
.map((entry) => {
|
||||
const path = entry.description.path.toLowerCase()
|
||||
const description = entry.description.description.toLowerCase()
|
||||
const score = terms.reduce(
|
||||
(total, forms) =>
|
||||
total +
|
||||
(forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) +
|
||||
(forms.some((form) => path.includes(form)) ? 8 : 0) +
|
||||
(forms.some((form) => description.includes(form)) ? 4 : 0) +
|
||||
(forms.some((form) => entry.searchText.includes(form)) ? 2 : 0),
|
||||
0,
|
||||
const ranked =
|
||||
exact !== undefined
|
||||
? [exact]
|
||||
: scoped
|
||||
.map((entry) => {
|
||||
const path = entry.description.path.toLowerCase()
|
||||
const description = entry.description.description.toLowerCase()
|
||||
const score = terms.reduce(
|
||||
(total, forms) =>
|
||||
total +
|
||||
(forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) +
|
||||
(forms.some((form) => path.includes(form)) ? 8 : 0) +
|
||||
(forms.some((form) => description.includes(form)) ? 4 : 0) +
|
||||
(forms.some((form) => entry.searchText.includes(form)) ? 2 : 0),
|
||||
0,
|
||||
)
|
||||
return { entry, score }
|
||||
})
|
||||
.filter(({ score }) => terms.length === 0 || score > 0)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.score - left.score ||
|
||||
left.entry.description.path.localeCompare(right.entry.description.path),
|
||||
)
|
||||
return { entry, score }
|
||||
})
|
||||
.filter(({ score }) => terms.length === 0 || score > 0)
|
||||
.sort((left, right) =>
|
||||
right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path))
|
||||
.map(({ entry }) => entry)
|
||||
.map(({ entry }) => entry)
|
||||
// Result paths are rendered as JavaScript expressions so each `path` is
|
||||
// directly usable as the call site (`await tools.github.list({ ... })` or
|
||||
// `await tools.ns["dashed-name"]({ ... })`). The signature is the pretty,
|
||||
@@ -711,10 +792,12 @@ export const make = <R>(
|
||||
const tool = resolve(tools, path, searchEnabled)
|
||||
let describedInput: unknown
|
||||
if (isDefinition(tool)) {
|
||||
if (externalArgs.length !== 1) throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
|
||||
if (externalArgs.length !== 1)
|
||||
throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
|
||||
describedInput = yield* Effect.try({
|
||||
try: () => decodeToolInput(tool, externalArgs[0]),
|
||||
catch: (cause) => new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`),
|
||||
catch: (cause) =>
|
||||
new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`),
|
||||
})
|
||||
}
|
||||
const input = isDefinition(tool) ? describedInput : externalArgs
|
||||
@@ -722,7 +805,7 @@ export const make = <R>(
|
||||
const currentCall = { index, name, input }
|
||||
if (isDefinition(tool)) {
|
||||
return yield* observeEnd(
|
||||
Effect.gen(function*() {
|
||||
Effect.gen(function* () {
|
||||
const raw = yield* runHost(Effect.suspend(() => tool.run(describedInput)))
|
||||
const result = yield* Effect.try({
|
||||
try: () => decodeToolOutput(tool, raw),
|
||||
@@ -734,7 +817,7 @@ export const make = <R>(
|
||||
)
|
||||
}
|
||||
return yield* observeEnd(
|
||||
Effect.gen(function*() {
|
||||
Effect.gen(function* () {
|
||||
return yield* decodeOutput(yield* runHost(Effect.suspend(() => tool(...externalArgs))), name)
|
||||
}),
|
||||
currentCall,
|
||||
|
||||
@@ -57,8 +57,7 @@ export type Options<I extends ToolSchema, O extends ToolSchema | undefined, R =
|
||||
export const isDefinition = <R = never>(value: unknown): value is Definition<R> =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "CodeModeTool"
|
||||
|
||||
const isEffectSchema = (schema: ToolSchema): schema is Schema.Decoder<unknown> & Schema.Top =>
|
||||
Schema.isSchema(schema)
|
||||
const isEffectSchema = (schema: ToolSchema): schema is Schema.Decoder<unknown> & Schema.Top => Schema.isSchema(schema)
|
||||
|
||||
const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown"
|
||||
|
||||
@@ -69,10 +68,12 @@ const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unkn
|
||||
export const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
||||
|
||||
/** Renders a property name as a valid TS object key: bare when an identifier, quoted otherwise. */
|
||||
const renderKey = (name: string): string => identifierSegment.test(name) ? name : JSON.stringify(name)
|
||||
const renderKey = (name: string): string => (identifierSegment.test(name) ? name : JSON.stringify(name))
|
||||
|
||||
const effectNumberSentinel = (schema: JsonSchema) =>
|
||||
schema.type === "string" && Array.isArray(schema.enum) && schema.enum.length === 1 &&
|
||||
schema.type === "string" &&
|
||||
Array.isArray(schema.enum) &&
|
||||
schema.enum.length === 1 &&
|
||||
(schema.enum[0] === "NaN" || schema.enum[0] === "Infinity" || schema.enum[0] === "-Infinity")
|
||||
|
||||
/**
|
||||
@@ -118,7 +119,8 @@ const docTags = (schema: JsonSchema): Array<string> => {
|
||||
*/
|
||||
const jsdoc = (description: string | undefined, tags: ReadonlyArray<string>, pad: string): string => {
|
||||
const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) =>
|
||||
line.replaceAll("*/", "* /").replace(/\s+$/, ""))
|
||||
line.replaceAll("*/", "* /").replace(/\s+$/, ""),
|
||||
)
|
||||
while (lines.length > 0 && lines[0]!.trim() === "") lines.shift()
|
||||
while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop()
|
||||
if (lines.length === 0) return ""
|
||||
@@ -127,7 +129,12 @@ const jsdoc = (description: string | undefined, tags: ReadonlyArray<string>, pad
|
||||
return `${pad}/**\n${body}\n${pad} */\n`
|
||||
}
|
||||
|
||||
const renderSchema = (schema: JsonSchema, ctx: RenderContext, depth = 0, seen: ReadonlySet<string> = new Set()): string => {
|
||||
const renderSchema = (
|
||||
schema: JsonSchema,
|
||||
ctx: RenderContext,
|
||||
depth = 0,
|
||||
seen: ReadonlySet<string> = new Set(),
|
||||
): string => {
|
||||
if (depth > MAX_RENDER_DEPTH) return "unknown"
|
||||
if (schema.$ref) {
|
||||
const name = schema.$ref.split("/").pop()
|
||||
@@ -146,13 +153,16 @@ const renderSchema = (schema: JsonSchema, ctx: RenderContext, depth = 0, seen: R
|
||||
if (
|
||||
alternatives.some((item) => item.type === "number") &&
|
||||
alternatives.every((item) => item.type === "number" || effectNumberSentinel(item))
|
||||
) return "number"
|
||||
)
|
||||
return "number"
|
||||
// An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]`
|
||||
// (no properties/items); render the bare shape as {} instead of `{} | Array<unknown>`.
|
||||
if (
|
||||
alternatives.length === 2 &&
|
||||
alternatives[0]?.type === "object" && alternatives[0].properties === undefined &&
|
||||
alternatives[1]?.type === "array" && alternatives[1].items === undefined
|
||||
alternatives[0]?.type === "object" &&
|
||||
alternatives[0].properties === undefined &&
|
||||
alternatives[1]?.type === "array" &&
|
||||
alternatives[1].items === undefined
|
||||
) {
|
||||
return "{}"
|
||||
}
|
||||
@@ -170,7 +180,8 @@ const renderSchema = (schema: JsonSchema, ctx: RenderContext, depth = 0, seen: R
|
||||
const required = new Set(schema.required ?? [])
|
||||
const properties = Object.entries(schema.properties ?? {})
|
||||
const additional = schema.additionalProperties
|
||||
const indexType = additional && typeof additional === "object" ? renderSchema(additional, ctx, depth + 1, seen) : undefined
|
||||
const indexType =
|
||||
additional && typeof additional === "object" ? renderSchema(additional, ctx, depth + 1, seen) : undefined
|
||||
const field = ([name, value]: readonly [string, JsonSchema]) =>
|
||||
`${renderKey(name)}${required.has(name) ? "" : "?"}: ${renderSchema(value, ctx, depth + 1, seen)}`
|
||||
|
||||
@@ -183,7 +194,9 @@ const renderSchema = (schema: JsonSchema, ctx: RenderContext, depth = 0, seen: R
|
||||
// Pretty: an indented block, each described field preceded by its JSDoc comment.
|
||||
if (properties.length === 0 && indexType === undefined) return "{}"
|
||||
const pad = " ".repeat(depth + 1)
|
||||
const lines = properties.map((entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)}`)
|
||||
const lines = properties.map(
|
||||
(entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)}`,
|
||||
)
|
||||
if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType}`)
|
||||
return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}`
|
||||
}
|
||||
@@ -262,7 +275,9 @@ export const inputProperties = <R>(definition: Definition<R>): Array<InputProper
|
||||
* fields; the default stays the compact single-line form.
|
||||
*/
|
||||
export const inputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
|
||||
isEffectSchema(definition.input) ? toTypeScript(definition.input, false, pretty) : jsonSchemaToTypeScript(definition.input, pretty)
|
||||
isEffectSchema(definition.input)
|
||||
? toTypeScript(definition.input, false, pretty)
|
||||
: jsonSchemaToTypeScript(definition.input, pretty)
|
||||
|
||||
/**
|
||||
* The model-visible TypeScript type of a tool's result; tools without an output schema
|
||||
|
||||
@@ -28,4 +28,7 @@ export class SandboxSet {
|
||||
}
|
||||
|
||||
export const isSandboxValue = (value: unknown): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet =>
|
||||
value instanceof SandboxDate || value instanceof SandboxRegExp || value instanceof SandboxMap || value instanceof SandboxSet
|
||||
value instanceof SandboxDate ||
|
||||
value instanceof SandboxRegExp ||
|
||||
value instanceof SandboxMap ||
|
||||
value instanceof SandboxSet
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Cause, Effect, Schema } from "effect"
|
||||
import { CodeMode, ExecuteInputSchema, ExecuteResultSchema, Tool, toolError, type ExecutionLimits } from "../src/index.js"
|
||||
import {
|
||||
CodeMode,
|
||||
ExecuteInputSchema,
|
||||
ExecuteResultSchema,
|
||||
Tool,
|
||||
toolError,
|
||||
type ExecutionLimits,
|
||||
} from "../src/index.js"
|
||||
import type { Definition } from "../src/tool.js"
|
||||
|
||||
const run = (tool: Definition<never>) =>
|
||||
@@ -75,11 +82,14 @@ describe("CodeMode host failure boundary", () => {
|
||||
output: Schema.Unknown,
|
||||
run: () =>
|
||||
Effect.succeed(
|
||||
new Proxy({}, {
|
||||
ownKeys: () => {
|
||||
throw new Error("host-output-secret")
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
ownKeys: () => {
|
||||
throw new Error("host-output-secret")
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
@@ -162,9 +172,7 @@ describe("CodeMode tool-call observation", () => {
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(calls).toStrictEqual([
|
||||
{ index: 0, name: "context.lookup", input: { query: "deployment failure" } },
|
||||
])
|
||||
expect(calls).toStrictEqual([{ index: 0, name: "context.lookup", input: { query: "deployment failure" } }])
|
||||
})
|
||||
|
||||
test("observes settled calls with outcome and duration", async () => {
|
||||
@@ -173,25 +181,26 @@ describe("CodeMode tool-call observation", () => {
|
||||
description: "Look up a value",
|
||||
input: Schema.Struct({ query: Schema.String }),
|
||||
output: Schema.String,
|
||||
run: ({ query }) =>
|
||||
query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query),
|
||||
run: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)),
|
||||
})
|
||||
|
||||
const runtime = CodeMode.make({
|
||||
tools: { context: { lookup } },
|
||||
onToolCallStart: (call) => Effect.sync(() => {
|
||||
events.push({ phase: "start", index: call.index, name: call.name })
|
||||
}),
|
||||
onToolCallEnd: (call) => Effect.sync(() => {
|
||||
expect(call.durationMs).toBeGreaterThanOrEqual(0)
|
||||
events.push({
|
||||
phase: "end",
|
||||
index: call.index,
|
||||
name: call.name,
|
||||
outcome: call.outcome,
|
||||
...(call.message === undefined ? {} : { message: call.message }),
|
||||
})
|
||||
}),
|
||||
onToolCallStart: (call) =>
|
||||
Effect.sync(() => {
|
||||
events.push({ phase: "start", index: call.index, name: call.name })
|
||||
}),
|
||||
onToolCallEnd: (call) =>
|
||||
Effect.sync(() => {
|
||||
expect(call.durationMs).toBeGreaterThanOrEqual(0)
|
||||
events.push({
|
||||
phase: "end",
|
||||
index: call.index,
|
||||
name: call.name,
|
||||
outcome: call.outcome,
|
||||
...(call.message === undefined ? {} : { message: call.message }),
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
const success = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "ok" })`))
|
||||
@@ -210,13 +219,15 @@ describe("CodeMode tool-call observation", () => {
|
||||
|
||||
describe("CodeMode console capture", () => {
|
||||
test("captures console output as bounded result logs", async () => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `
|
||||
const returned = console.log("Thread info:", { name: "Demo", count: 2 })
|
||||
console.warn("careful")
|
||||
return returned
|
||||
`,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
ok: true,
|
||||
@@ -228,39 +239,45 @@ describe("CodeMode console capture", () => {
|
||||
})
|
||||
|
||||
test("keeps logs captured before failures", async () => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `
|
||||
console.log("before failure")
|
||||
throw new Error("boom")
|
||||
`,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.ok ? undefined : result.logs).toStrictEqual(["before failure"])
|
||||
expect(result.ok ? undefined : result.error.message).toBe("Uncaught: boom")
|
||||
})
|
||||
|
||||
test("prints NaN and Infinity literally instead of the JSON null", async () => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `
|
||||
console.log(NaN)
|
||||
console.log(Infinity, -Infinity)
|
||||
console.log({ ratio: NaN, bounds: [Infinity] })
|
||||
return null
|
||||
`,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.logs).toStrictEqual(["NaN", "Infinity -Infinity", '{"ratio":NaN,"bounds":[Infinity]}'])
|
||||
})
|
||||
|
||||
test("renders sandbox values nested inside logged containers", async () => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `
|
||||
console.log({ m: new Map([["a", 1]]), when: new Date(0), r: /ab/g, s: new Set([1, 2]) })
|
||||
console.log([new Date(0)])
|
||||
return null
|
||||
`,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.logs).toStrictEqual([
|
||||
@@ -270,38 +287,40 @@ describe("CodeMode console capture", () => {
|
||||
})
|
||||
|
||||
test("console formatting is total: cycles and opaque references render as markers", async () => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `
|
||||
const m = new Map()
|
||||
m.set("self", m)
|
||||
console.log({ box: m })
|
||||
console.log({ fn: (x) => x, ok: 1 })
|
||||
return null
|
||||
`,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.logs).toStrictEqual([
|
||||
'{"box":Map(1) [["self",[Circular]]]}',
|
||||
'{"fn":[CodeMode reference],"ok":1}',
|
||||
])
|
||||
expect(result.logs).toStrictEqual(['{"box":Map(1) [["self",[Circular]]]}', '{"fn":[CodeMode reference],"ok":1}'])
|
||||
})
|
||||
|
||||
test("console.table renders sandbox value cells", async () => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `
|
||||
console.table([{ when: new Date(0), n: NaN }])
|
||||
return null
|
||||
`,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.logs).toStrictEqual(["(index)\twhen\tn\n0\t1970-01-01T00:00:00.000Z\tNaN"])
|
||||
})
|
||||
|
||||
test("captures console.dir and console.table output", async () => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `
|
||||
console.dir({ nested: { ok: true } })
|
||||
console.table([
|
||||
{ name: "Kit", count: 1, hidden: "x" },
|
||||
@@ -309,15 +328,13 @@ describe("CodeMode console capture", () => {
|
||||
], ["name", "count"])
|
||||
return "done"
|
||||
`,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
ok: true,
|
||||
value: "done",
|
||||
logs: [
|
||||
'{"nested":{"ok":true}}',
|
||||
"(index)\tname\tcount\n0\tKit\t1\n1\tOlive\t2",
|
||||
],
|
||||
logs: ['{"nested":{"ok":true}}', "(index)\tname\tcount\n0\tKit\t1\n1\tOlive\t2"],
|
||||
toolCalls: [],
|
||||
})
|
||||
})
|
||||
@@ -325,9 +342,11 @@ describe("CodeMode console capture", () => {
|
||||
|
||||
describe("CodeMode output budget", () => {
|
||||
test("absent maxOutputBytes means no truncation at all", async () => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `console.log("z".repeat(50_000)); return "x".repeat(100_000)`,
|
||||
}))
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `console.log("z".repeat(50_000)); return "x".repeat(100_000)`,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
@@ -338,29 +357,35 @@ describe("CodeMode output budget", () => {
|
||||
|
||||
test("truncates an oversized result value with a marker instead of failing", async () => {
|
||||
const limits: ExecutionLimits = { maxOutputBytes: 40 }
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `return { data: "${"x".repeat(200)}" }`,
|
||||
limits,
|
||||
}))
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `return { data: "${"x".repeat(200)}" }`,
|
||||
limits,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(typeof result.value).toBe("string")
|
||||
expect(result.value).toMatch(/^\{"data":"x+ \[result truncated: \d+ bytes exceeds the 40-byte output limit; return a smaller value\]$/)
|
||||
expect(result.value).toMatch(
|
||||
/^\{"data":"x+ \[result truncated: \d+ bytes exceeds the 40-byte output limit; return a smaller value\]$/,
|
||||
)
|
||||
expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
|
||||
})
|
||||
|
||||
test("keeps leading logs within the remaining budget and marks the cut", async () => {
|
||||
const limits: ExecutionLimits = { maxOutputBytes: 40 }
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `
|
||||
console.log("first line")
|
||||
console.log("${"y".repeat(200)}")
|
||||
return "ok"
|
||||
`,
|
||||
limits,
|
||||
}))
|
||||
limits,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
@@ -370,12 +395,14 @@ describe("CodeMode output budget", () => {
|
||||
})
|
||||
|
||||
test("does not mark results within the budget", async () => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `
|
||||
console.log("fits")
|
||||
return { fits: true }
|
||||
`,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
expect(result).toStrictEqual({
|
||||
ok: true,
|
||||
value: { fits: true },
|
||||
@@ -395,18 +422,21 @@ describe("CodeMode schema flexibility", () => {
|
||||
properties: { id: { type: "string" }, count: { type: "number" } },
|
||||
required: ["id"],
|
||||
},
|
||||
run: (input) => Effect.sync(() => {
|
||||
observed.push(input)
|
||||
return { echoed: input }
|
||||
}),
|
||||
run: (input) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(input)
|
||||
return { echoed: input }
|
||||
}),
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { adapter: { call } } })
|
||||
|
||||
expect(runtime.catalog()).toStrictEqual([{
|
||||
path: "adapter.call",
|
||||
description: "Call an adapter-described tool",
|
||||
signature: "tools.adapter.call(input: { id: string; count?: number }): Promise<unknown>",
|
||||
}])
|
||||
expect(runtime.catalog()).toStrictEqual([
|
||||
{
|
||||
path: "adapter.call",
|
||||
description: "Call an adapter-described tool",
|
||||
signature: "tools.adapter.call(input: { id: string; count?: number }): Promise<unknown>",
|
||||
},
|
||||
])
|
||||
|
||||
// JSON Schema is render-only: mistyped input passes through unvalidated.
|
||||
const result = await Effect.runPromise(runtime.execute(`return await tools.adapter.call({ id: 42 })`))
|
||||
@@ -421,17 +451,25 @@ describe("CodeMode schema flexibility", () => {
|
||||
input: { type: "object", properties: { login: { type: "string" } }, required: ["login"] },
|
||||
output: {
|
||||
$ref: "#/$defs/User",
|
||||
$defs: { User: { type: "object", properties: { login: { type: "string" }, id: { type: "number" } }, required: ["login", "id"] } },
|
||||
$defs: {
|
||||
User: {
|
||||
type: "object",
|
||||
properties: { login: { type: "string" }, id: { type: "number" } },
|
||||
required: ["login", "id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
run: () => Effect.succeed({ login: "kit", id: 7 }),
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { users: { lookup } } })
|
||||
|
||||
expect(runtime.catalog()).toStrictEqual([{
|
||||
path: "users.lookup",
|
||||
description: "Look up a user",
|
||||
signature: "tools.users.lookup(input: { login: string }): Promise<{ login: string; id: number }>",
|
||||
}])
|
||||
expect(runtime.catalog()).toStrictEqual([
|
||||
{
|
||||
path: "users.lookup",
|
||||
description: "Look up a user",
|
||||
signature: "tools.users.lookup(input: { login: string }): Promise<{ login: string; id: number }>",
|
||||
},
|
||||
])
|
||||
|
||||
const result = await Effect.runPromise(runtime.execute(`return await tools.users.lookup({ login: "kit" })`))
|
||||
expect(result.ok).toBe(true)
|
||||
@@ -478,16 +516,20 @@ describe("CodeMode public contract", () => {
|
||||
expect(agentTool.input).toBe(ExecuteInputSchema)
|
||||
expect(agentTool.output).toBe(ExecuteResultSchema)
|
||||
expect(agentTool.description).toBe(runtime.instructions())
|
||||
expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(projected)))).toStrictEqual(projected)
|
||||
expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(projected)))).toStrictEqual(
|
||||
projected,
|
||||
)
|
||||
})
|
||||
|
||||
test("inlines a COMPLETE small catalog and keeps search registered but unadvertised", async () => {
|
||||
const runtime = CodeMode.make({ tools })
|
||||
expect(runtime.catalog()).toStrictEqual([{
|
||||
path: "orders.lookup",
|
||||
description: "Look up an order by ID",
|
||||
signature: "tools.orders.lookup(input: { id: string }): Promise<{ id: string; status: string }>",
|
||||
}])
|
||||
expect(runtime.catalog()).toStrictEqual([
|
||||
{
|
||||
path: "orders.lookup",
|
||||
description: "Look up an order by ID",
|
||||
signature: "tools.orders.lookup(input: { id: string }): Promise<{ id: string; status: string }>",
|
||||
},
|
||||
])
|
||||
expect(runtime.instructions()).toContain("Available tools (COMPLETE list")
|
||||
expect(runtime.instructions()).toContain("- orders (1 tool)")
|
||||
expect(runtime.instructions()).toContain(
|
||||
@@ -502,11 +544,13 @@ describe("CodeMode public contract", () => {
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.value).toStrictEqual({
|
||||
items: [{
|
||||
path: "tools.orders.lookup",
|
||||
description: "Look up an order by ID",
|
||||
signature: "tools.orders.lookup(input: {\n id: string\n}): Promise<{\n id: string\n status: string\n}>",
|
||||
}],
|
||||
items: [
|
||||
{
|
||||
path: "tools.orders.lookup",
|
||||
description: "Look up an order by ID",
|
||||
signature: "tools.orders.lookup(input: {\n id: string\n}): Promise<{\n id: string\n status: string\n}>",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
})
|
||||
}
|
||||
@@ -521,31 +565,43 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
|
||||
|
||||
expect(runtime.catalog()).toStrictEqual([{
|
||||
path: "context7.resolve-library-id",
|
||||
description: "Resolve a library ID",
|
||||
signature: 'tools.context7["resolve-library-id"](input: { libraryName: string }): Promise<string>',
|
||||
}])
|
||||
expect(runtime.instructions()).toContain('tools.context7["resolve-library-id"](input: { libraryName: string }): Promise<string>')
|
||||
expect(runtime.catalog()).toStrictEqual([
|
||||
{
|
||||
path: "context7.resolve-library-id",
|
||||
description: "Resolve a library ID",
|
||||
signature: 'tools.context7["resolve-library-id"](input: { libraryName: string }): Promise<string>',
|
||||
},
|
||||
])
|
||||
expect(runtime.instructions()).toContain(
|
||||
'tools.context7["resolve-library-id"](input: { libraryName: string }): Promise<string>',
|
||||
)
|
||||
|
||||
const search = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "resolve library id" })`))
|
||||
const search = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: "resolve library id" })`),
|
||||
)
|
||||
expect(search.ok).toBe(true)
|
||||
if (search.ok) {
|
||||
expect(search.value).toStrictEqual({
|
||||
items: [{
|
||||
path: 'tools.context7["resolve-library-id"]',
|
||||
description: "Resolve a library ID",
|
||||
signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string\n}): Promise<string>',
|
||||
}],
|
||||
items: [
|
||||
{
|
||||
path: 'tools.context7["resolve-library-id"]',
|
||||
description: "Resolve a library ID",
|
||||
signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string\n}): Promise<string>',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
})
|
||||
}
|
||||
|
||||
const call = await Effect.runPromise(runtime.execute(`return await tools.context7["resolve-library-id"]({ libraryName: "TypeScript" })`))
|
||||
const call = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.context7["resolve-library-id"]({ libraryName: "TypeScript" })`),
|
||||
)
|
||||
expect(call.ok).toBe(true)
|
||||
if (call.ok) expect(call.value).toBe("/resolved/TypeScript")
|
||||
|
||||
const exact = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: 'tools.context7["resolve-library-id"]' })`))
|
||||
const exact = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: 'tools.context7["resolve-library-id"]' })`),
|
||||
)
|
||||
expect(exact.ok).toBe(true)
|
||||
if (exact.ok) expect((exact.value as { total: number }).total).toBe(1)
|
||||
})
|
||||
@@ -561,7 +617,9 @@ describe("CodeMode public contract", () => {
|
||||
expect(instructions.indexOf("## Rules")).toBeLessThan(instructions.indexOf("## Syntax"))
|
||||
expect(instructions.indexOf("## Syntax")).toBeLessThan(instructions.indexOf("\n## Available tools (COMPLETE list"))
|
||||
// The workflow carries the result-shape guidance; Rules only add content beyond it.
|
||||
expect(instructions).toContain('`const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string')
|
||||
expect(instructions).toContain(
|
||||
'`const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string',
|
||||
)
|
||||
expect(instructions).toContain("Return only the fields you need")
|
||||
expect(instructions).toContain("raw payloads get truncated and waste context")
|
||||
expect(instructions).toContain("`const res = await tools.<namespace>.<tool>(input)`")
|
||||
@@ -584,8 +642,12 @@ describe("CodeMode public contract", () => {
|
||||
expect(partial).toContain(
|
||||
'1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })` - short phrases like "list issues" work best.',
|
||||
)
|
||||
expect(partial).toContain("Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`")
|
||||
expect(partial).toContain('- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.')
|
||||
expect(partial).toContain(
|
||||
"Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`",
|
||||
)
|
||||
expect(partial).toContain(
|
||||
'- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.',
|
||||
)
|
||||
expect(partial).not.toContain("total_count")
|
||||
expect(partial).not.toContain("tools.orders.lookup({")
|
||||
})
|
||||
@@ -604,7 +666,9 @@ describe("CodeMode public contract", () => {
|
||||
expect(instructions).not.toContain("instanceof Error")
|
||||
expect(instructions).not.toContain("splice")
|
||||
// The data-boundary note survives.
|
||||
expect(instructions).toContain("Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.")
|
||||
expect(instructions).toContain(
|
||||
"Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.",
|
||||
)
|
||||
})
|
||||
|
||||
test("zero tools keep minimal sections and the no-tools notice", () => {
|
||||
@@ -635,18 +699,22 @@ describe("CodeMode public contract", () => {
|
||||
tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } },
|
||||
discovery: { maxInlineCatalogTokens: 0 },
|
||||
})
|
||||
expect(runtime.instructions()).toContain("Available tools (PARTIAL - 0 of 3 shown; find the rest with tools.$codemode.search)")
|
||||
expect(runtime.instructions()).toContain(
|
||||
"Available tools (PARTIAL - 0 of 3 shown; find the rest with tools.$codemode.search)",
|
||||
)
|
||||
expect(runtime.instructions()).toContain("- thread (2 tools, none shown)")
|
||||
expect(runtime.instructions()).toContain("- orders (1 tool, none shown)")
|
||||
expect(runtime.instructions()).toMatch(/\$codemode\.search/)
|
||||
expect(runtime.instructions()).not.toMatch(/tools\.thread\.uploadFile\(input/)
|
||||
|
||||
const result = await Effect.runPromise(runtime.execute(`
|
||||
const result = await Effect.runPromise(
|
||||
runtime.execute(`
|
||||
return await tools.$codemode.search({
|
||||
query: "send message attachment upload file to current Discord thread",
|
||||
limit: 2
|
||||
})
|
||||
`))
|
||||
`),
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value).toStrictEqual({
|
||||
@@ -666,19 +734,27 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
expect(result.toolCalls).toStrictEqual([{ name: "$codemode.search" }])
|
||||
|
||||
const variants = await Effect.runPromise(runtime.execute(`
|
||||
const variants = await Effect.runPromise(
|
||||
runtime.execute(`
|
||||
return await Promise.all([
|
||||
tools.$codemode.search({ query: "file" }),
|
||||
tools.$codemode.search({ query: "image" })
|
||||
])
|
||||
`))
|
||||
`),
|
||||
)
|
||||
expect(variants.ok).toBe(true)
|
||||
if (variants.ok) {
|
||||
expect((variants.value as Array<{ items: Array<{ path: string }> }>)[0]?.items[0]?.path).toBe("tools.thread.uploadFile")
|
||||
expect((variants.value as Array<{ items: Array<{ path: string }> }>)[1]?.items[0]?.path).toBe("tools.thread.generateImage")
|
||||
expect((variants.value as Array<{ items: Array<{ path: string }> }>)[0]?.items[0]?.path).toBe(
|
||||
"tools.thread.uploadFile",
|
||||
)
|
||||
expect((variants.value as Array<{ items: Array<{ path: string }> }>)[1]?.items[0]?.path).toBe(
|
||||
"tools.thread.generateImage",
|
||||
)
|
||||
}
|
||||
|
||||
const removed = await Effect.runPromise(runtime.execute(`return await tools.$codemode.describe({ path: "thread.uploadFile" })`))
|
||||
const removed = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.describe({ path: "thread.uploadFile" })`),
|
||||
)
|
||||
expect(removed.ok).toBe(false)
|
||||
if (!removed.ok) expect(removed.error.kind).toBe("UnknownTool")
|
||||
})
|
||||
@@ -706,15 +782,19 @@ describe("CodeMode public contract", () => {
|
||||
}
|
||||
|
||||
for (const query of ["many.tool13", "tools.many.tool13"]) {
|
||||
const exact = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`))
|
||||
const exact = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`),
|
||||
)
|
||||
expect(exact.ok).toBe(true)
|
||||
if (exact.ok) {
|
||||
expect(exact.value).toStrictEqual({
|
||||
items: [{
|
||||
path: "tools.many.tool13",
|
||||
description: "Numbered tool 13",
|
||||
signature: "tools.many.tool13(input: {\n id: string\n}): Promise<string>",
|
||||
}],
|
||||
items: [
|
||||
{
|
||||
path: "tools.many.tool13",
|
||||
description: "Numbered tool 13",
|
||||
signature: "tools.many.tool13(input: {\n id: string\n}): Promise<string>",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
})
|
||||
}
|
||||
@@ -737,20 +817,23 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
|
||||
// Empty query + namespace browses just that namespace, alphabetical by path.
|
||||
const browse = await Effect.runPromise(runtime.execute(
|
||||
`return await tools.$codemode.search({ query: "", namespace: "github" })`,
|
||||
))
|
||||
const browse = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: "", namespace: "github" })`),
|
||||
)
|
||||
expect(browse.ok).toBe(true)
|
||||
if (browse.ok) {
|
||||
const value = browse.value as { items: Array<{ path: string }>; total: number }
|
||||
expect(value.total).toBe(2)
|
||||
expect(value.items.map((item) => item.path)).toStrictEqual(["tools.github.create_issue", "tools.github.list_issues"])
|
||||
expect(value.items.map((item) => item.path)).toStrictEqual([
|
||||
"tools.github.create_issue",
|
||||
"tools.github.list_issues",
|
||||
])
|
||||
}
|
||||
|
||||
// A query + namespace ranks within that namespace only.
|
||||
const scoped = await Effect.runPromise(runtime.execute(
|
||||
`return await tools.$codemode.search({ query: "issues", namespace: "linear" })`,
|
||||
))
|
||||
const scoped = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: "linear" })`),
|
||||
)
|
||||
expect(scoped.ok).toBe(true)
|
||||
if (scoped.ok) {
|
||||
const value = scoped.value as { items: Array<{ path: string }>; total: number }
|
||||
@@ -758,9 +841,9 @@ describe("CodeMode public contract", () => {
|
||||
expect(value.items[0]?.path).toBe("tools.linear.list_issues")
|
||||
}
|
||||
|
||||
const invalid = await Effect.runPromise(runtime.execute(
|
||||
`return await tools.$codemode.search({ query: "issues", namespace: 7 })`,
|
||||
))
|
||||
const invalid = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: 7 })`),
|
||||
)
|
||||
expect(invalid.ok).toBe(false)
|
||||
if (!invalid.ok) expect(invalid.error.kind).toBe("InvalidToolInput")
|
||||
})
|
||||
@@ -785,9 +868,9 @@ describe("CodeMode public contract", () => {
|
||||
|
||||
// "attachment" appears in neither path nor description - only in the input schema's
|
||||
// property names, which the searchable text includes.
|
||||
const byParameter = await Effect.runPromise(runtime.execute(
|
||||
`return await tools.$codemode.search({ query: "attachment" })`,
|
||||
))
|
||||
const byParameter = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: "attachment" })`),
|
||||
)
|
||||
expect(byParameter.ok).toBe(true)
|
||||
if (byParameter.ok) {
|
||||
const value = byParameter.value as { items: Array<{ path: string }>; total: number }
|
||||
@@ -796,9 +879,9 @@ describe("CodeMode public contract", () => {
|
||||
}
|
||||
|
||||
// Substring matching: a partial word ("docum") still hits the description.
|
||||
const bySubstring = await Effect.runPromise(runtime.execute(
|
||||
`return await tools.$codemode.search({ query: "docum" })`,
|
||||
))
|
||||
const bySubstring = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: "docum" })`),
|
||||
)
|
||||
expect(bySubstring.ok).toBe(true)
|
||||
if (bySubstring.ok) {
|
||||
const value = bySubstring.value as { items: Array<{ path: string }>; total: number }
|
||||
@@ -825,9 +908,9 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
|
||||
// "issues" still finds the singular-only tool (term OR singular(term) per field)...
|
||||
const plural = await Effect.runPromise(runtime.execute(
|
||||
`return await tools.$codemode.search({ query: "issues", namespace: "tracker" })`,
|
||||
))
|
||||
const plural = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: "tracker" })`),
|
||||
)
|
||||
expect(plural.ok).toBe(true)
|
||||
if (plural.ok) {
|
||||
const value = plural.value as { items: Array<{ path: string }>; total: number }
|
||||
@@ -836,14 +919,15 @@ describe("CodeMode public contract", () => {
|
||||
}
|
||||
|
||||
// ...while a true "issues" path match still outranks the singular-only description match.
|
||||
const ranked = await Effect.runPromise(runtime.execute(
|
||||
`return await tools.$codemode.search({ query: "issues" })`,
|
||||
))
|
||||
const ranked = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "issues" })`))
|
||||
expect(ranked.ok).toBe(true)
|
||||
if (ranked.ok) {
|
||||
const value = ranked.value as { items: Array<{ path: string }>; total: number }
|
||||
expect(value.total).toBe(2)
|
||||
expect(value.items.map((item) => item.path)).toStrictEqual(["tools.github.list_issues", "tools.tracker.fetch_all"])
|
||||
expect(value.items.map((item) => item.path)).toStrictEqual([
|
||||
"tools.github.list_issues",
|
||||
"tools.tracker.fetch_all",
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -882,8 +966,12 @@ describe("CodeMode public contract", () => {
|
||||
run: () => Effect.succeed("ok"),
|
||||
})
|
||||
const expensive = Tool.make({
|
||||
description: "An expensive tool whose description alone consumes far more than the remaining inline catalog byte budget for this runtime",
|
||||
input: Schema.Struct({ someRatherLongParameterName: Schema.String, anotherEvenLongerParameterName: Schema.Number }),
|
||||
description:
|
||||
"An expensive tool whose description alone consumes far more than the remaining inline catalog byte budget for this runtime",
|
||||
input: Schema.Struct({
|
||||
someRatherLongParameterName: Schema.String,
|
||||
anotherEvenLongerParameterName: Schema.Number,
|
||||
}),
|
||||
output: Schema.String,
|
||||
run: () => Effect.succeed("ok"),
|
||||
})
|
||||
@@ -896,7 +984,9 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
|
||||
const instructions = runtime.instructions()
|
||||
expect(instructions).toContain("Available tools (PARTIAL - 2 of 3 shown; find the rest with tools.$codemode.search)")
|
||||
expect(instructions).toContain(
|
||||
"Available tools (PARTIAL - 2 of 3 shown; find the rest with tools.$codemode.search)",
|
||||
)
|
||||
expect(instructions).toContain("- alpha (2 tools, 1 shown)")
|
||||
expect(instructions).toContain(" - tools.alpha.cheap(input: { q: string }): Promise<string> // Cheap")
|
||||
expect(instructions).not.toContain("tools.alpha.expensive(")
|
||||
@@ -912,10 +1002,11 @@ describe("CodeMode public contract", () => {
|
||||
description: "Double a number",
|
||||
input: Schema.Struct({ value: Schema.NumberFromString }),
|
||||
output: Schema.NumberFromString,
|
||||
run: ({ value }) => Effect.sync(() => {
|
||||
observed.push(value)
|
||||
return String(value * 2)
|
||||
}),
|
||||
run: ({ value }) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(value)
|
||||
return String(value * 2)
|
||||
}),
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: { math: { double: transformed } },
|
||||
@@ -934,9 +1025,11 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
|
||||
test("returns JSON-safe data and normalizes undefined to null", async () => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `return { top: undefined, nested: [1, undefined] }`,
|
||||
}))
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `return { top: undefined, nested: [1, undefined] }`,
|
||||
}),
|
||||
)
|
||||
expect(result).toStrictEqual({
|
||||
ok: true,
|
||||
value: { top: null, nested: [1, null] },
|
||||
@@ -947,18 +1040,20 @@ describe("CodeMode public contract", () => {
|
||||
|
||||
test("rejects invalid configuration and discovery limits", async () => {
|
||||
expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: 0 } })).toThrow(RangeError)
|
||||
expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow(RangeError)
|
||||
expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow(
|
||||
RangeError,
|
||||
)
|
||||
expect(() => CodeMode.execute({ code: "return 1", limits: { maxToolCalls: -1 } })).toThrow(RangeError)
|
||||
expect(() => CodeMode.execute({ code: "return 1", limits: { maxOutputBytes: -1 } })).toThrow(RangeError)
|
||||
|
||||
expect(() => CodeMode.make({ tools, discovery: { maxInlineCatalogTokens: -1 } })).toThrow(RangeError)
|
||||
|
||||
const result = await Effect.runPromise(CodeMode.make({
|
||||
tools,
|
||||
discovery: { maxInlineCatalogTokens: 0 },
|
||||
}).execute(
|
||||
`return await tools.$codemode.search({ query: "order", limit: 0.5 })`,
|
||||
))
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.make({
|
||||
tools,
|
||||
discovery: { maxInlineCatalogTokens: 0 },
|
||||
}).execute(`return await tools.$codemode.search({ query: "order", limit: 0.5 })`),
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.error.kind).toBe("InvalidToolInput")
|
||||
@@ -979,14 +1074,16 @@ describe("CodeMode public contract", () => {
|
||||
output: Schema.Number,
|
||||
run: () => Effect.succeed(1),
|
||||
})
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
tools: { host: { count: counter } },
|
||||
code: `
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
tools: { host: { count: counter } },
|
||||
code: `
|
||||
let total = 0
|
||||
for (let i = 0; i < 150; i += 1) total += await tools.host.count({})
|
||||
return total
|
||||
`,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
expect(result).toMatchObject({ ok: true, value: 150 })
|
||||
if (result.ok) expect(result.toolCalls.length).toBe(150)
|
||||
})
|
||||
@@ -1008,8 +1105,6 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
|
||||
test("reserves the discovery namespace", () => {
|
||||
expect(() => CodeMode.make({ tools: { $codemode: { lookup } } })).toThrow(
|
||||
/reserved for CodeMode discovery tools/,
|
||||
)
|
||||
expect(() => CodeMode.make({ tools: { $codemode: { lookup } } })).toThrow(/reserved for CodeMode discovery tools/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,10 +36,12 @@ const error = async (code: string) => {
|
||||
|
||||
describe("Object.keys over tool references", () => {
|
||||
test("enumerates top-level namespaces (the transcript program)", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
const namespaces = Object.keys(tools)
|
||||
return { namespaces, count: namespaces.length }
|
||||
`)).toEqual({ namespaces: ["github", "memory", "playwright"], count: 3 })
|
||||
`),
|
||||
).toEqual({ namespaces: ["github", "memory", "playwright"], count: 3 })
|
||||
})
|
||||
|
||||
test("enumerates tool names at a nested namespace", async () => {
|
||||
@@ -92,7 +94,8 @@ describe("Object.keys over arrays", () => {
|
||||
|
||||
describe("for...in", () => {
|
||||
test("iterates own enumerable keys of a plain object with break/continue", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
const seen = []
|
||||
for (const key in { a: 1, b: 2, c: 3, d: 4 }) {
|
||||
if (key === "b") continue
|
||||
@@ -100,41 +103,50 @@ describe("for...in", () => {
|
||||
seen.push(key)
|
||||
}
|
||||
return seen
|
||||
`)).toEqual(["a", "c"])
|
||||
`),
|
||||
).toEqual(["a", "c"])
|
||||
})
|
||||
|
||||
test("iterates index strings over arrays", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
const indexes = []
|
||||
for (const i in ["x", "y", "z"]) {
|
||||
if (i === "2") break
|
||||
indexes.push(i)
|
||||
}
|
||||
return indexes
|
||||
`)).toEqual(["0", "1"])
|
||||
`),
|
||||
).toEqual(["0", "1"])
|
||||
})
|
||||
|
||||
test("supports let declarations and bare identifiers", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
let last = ""
|
||||
for (let key in { a: 1, b: 2 }) last = key
|
||||
return last
|
||||
`)).toBe("b")
|
||||
expect(await value(`
|
||||
`),
|
||||
).toBe("b")
|
||||
expect(
|
||||
await value(`
|
||||
let key = "before"
|
||||
for (key in { only: 1 }) {}
|
||||
return key
|
||||
`)).toBe("only")
|
||||
`),
|
||||
).toBe("only")
|
||||
})
|
||||
|
||||
test("enumerates namespaces and tools from the host tool tree", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
const names = []
|
||||
for (const ns in tools) {
|
||||
for (const name in tools[ns]) names.push(ns + "." + name)
|
||||
}
|
||||
return names
|
||||
`)).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate"])
|
||||
`),
|
||||
).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate"])
|
||||
})
|
||||
|
||||
test("unsupported values fail with a hint at for...of and Object.keys", async () => {
|
||||
|
||||
@@ -143,21 +143,40 @@ describe("H1: NaN/Infinity flow as intermediates and normalize to null at the bo
|
||||
|
||||
describe("Error values and instanceof", () => {
|
||||
test("new Error carries name/message and is instanceof Error", async () => {
|
||||
expect(await value(`const e = new Error("boom"); return [e instanceof Error, e.name, e.message]`)).toEqual([true, "Error", "boom"])
|
||||
expect(await value(`const e = new Error("boom"); return [e instanceof Error, e.name, e.message]`)).toEqual([
|
||||
true,
|
||||
"Error",
|
||||
"boom",
|
||||
])
|
||||
})
|
||||
|
||||
test("Error without new behaves like new Error", async () => {
|
||||
expect(await value(`const e = Error("plain"); return [e instanceof Error, e.name, e.message]`)).toEqual([true, "Error", "plain"])
|
||||
expect(await value(`const e = new Error(); return [e.name, e.message, e instanceof Error]`)).toEqual(["Error", "", true])
|
||||
expect(await value(`const e = Error("plain"); return [e instanceof Error, e.name, e.message]`)).toEqual([
|
||||
true,
|
||||
"Error",
|
||||
"plain",
|
||||
])
|
||||
expect(await value(`const e = new Error(); return [e.name, e.message, e instanceof Error]`)).toEqual([
|
||||
"Error",
|
||||
"",
|
||||
true,
|
||||
])
|
||||
})
|
||||
|
||||
test("specific error types are instanceof themselves and Error, not each other", async () => {
|
||||
expect(await value(`const e = new TypeError("t"); return [e instanceof TypeError, e instanceof Error, e instanceof RangeError]`)).toEqual([true, true, false])
|
||||
expect(
|
||||
await value(
|
||||
`const e = new TypeError("t"); return [e instanceof TypeError, e instanceof Error, e instanceof RangeError]`,
|
||||
),
|
||||
).toEqual([true, true, false])
|
||||
expect(await value(`return new Error("e") instanceof TypeError`)).toBe(false)
|
||||
})
|
||||
|
||||
test("thrown errors keep instanceof through try/catch", async () => {
|
||||
expect(await value(`try { throw new Error("x") } catch (e) { return [e instanceof Error, e.message] }`)).toEqual([true, "x"])
|
||||
expect(await value(`try { throw new Error("x") } catch (e) { return [e instanceof Error, e.message] }`)).toEqual([
|
||||
true,
|
||||
"x",
|
||||
])
|
||||
})
|
||||
|
||||
test("interpreter runtime failures are caught as Error values", async () => {
|
||||
@@ -168,33 +187,48 @@ describe("Error values and instanceof", () => {
|
||||
test("caught failures carry the constructor name the real-JS failure would have", async () => {
|
||||
// JSON.parse throws SyntaxError: name and specific-instanceof both carry through, and the
|
||||
// message keeps the engine's position detail.
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
try { JSON.parse("{oops") } catch (e) {
|
||||
return [e.name, e instanceof SyntaxError, e instanceof Error, e instanceof TypeError, e.message.includes("JSON")]
|
||||
}
|
||||
`)).toEqual(["SyntaxError", true, true, false, true])
|
||||
expect(await value(`try { undeclared() } catch (e) { return [e.name, e instanceof ReferenceError] }`))
|
||||
.toEqual(["ReferenceError", true])
|
||||
expect(await value(`try { const c = 1; c = 2 } catch (e) { return [e.name, e instanceof TypeError] }`))
|
||||
.toEqual(["TypeError", true])
|
||||
expect(await value(`try { "a".normalize("NOPE") } catch (e) { return [e.name, e instanceof RangeError] }`))
|
||||
.toEqual(["RangeError", true])
|
||||
expect(await value(`try { "a".match("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`))
|
||||
.toEqual(["SyntaxError", true])
|
||||
expect(await value(`try { new RegExp("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`))
|
||||
.toEqual(["SyntaxError", true])
|
||||
`),
|
||||
).toEqual(["SyntaxError", true, true, false, true])
|
||||
expect(await value(`try { undeclared() } catch (e) { return [e.name, e instanceof ReferenceError] }`)).toEqual([
|
||||
"ReferenceError",
|
||||
true,
|
||||
])
|
||||
expect(await value(`try { const c = 1; c = 2 } catch (e) { return [e.name, e instanceof TypeError] }`)).toEqual([
|
||||
"TypeError",
|
||||
true,
|
||||
])
|
||||
expect(await value(`try { "a".normalize("NOPE") } catch (e) { return [e.name, e instanceof RangeError] }`)).toEqual(
|
||||
["RangeError", true],
|
||||
)
|
||||
expect(await value(`try { "a".match("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)).toEqual([
|
||||
"SyntaxError",
|
||||
true,
|
||||
])
|
||||
expect(await value(`try { new RegExp("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)).toEqual([
|
||||
"SyntaxError",
|
||||
true,
|
||||
])
|
||||
})
|
||||
|
||||
test("diagnostics without a specific real-JS analogue are named plain Error", async () => {
|
||||
expect(await value(`try { JSON.parse(5) } catch (e) { return [e.name, e instanceof Error] }`))
|
||||
.toEqual(["Error", true])
|
||||
expect(await value(`try { JSON.parse(5) } catch (e) { return [e.name, e instanceof Error] }`)).toEqual([
|
||||
"Error",
|
||||
true,
|
||||
])
|
||||
})
|
||||
|
||||
test("Promise.allSettled rejection reasons are Error values", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
const settled = await Promise.allSettled([Promise.reject(new Error("b"))])
|
||||
return [settled[0].reason instanceof Error, settled[0].reason.message]
|
||||
`)).toEqual([true, "b"])
|
||||
`),
|
||||
).toEqual([true, "b"])
|
||||
})
|
||||
|
||||
test("non-error thrown values are not instanceof Error", async () => {
|
||||
@@ -203,7 +237,11 @@ describe("Error values and instanceof", () => {
|
||||
})
|
||||
|
||||
test("plain data is never instanceof Error", async () => {
|
||||
expect(await value(`return [({}) instanceof Error, "s" instanceof Error, null instanceof Error]`)).toEqual([false, false, false])
|
||||
expect(await value(`return [({}) instanceof Error, "s" instanceof Error, null instanceof Error]`)).toEqual([
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
])
|
||||
})
|
||||
|
||||
test("error values still serialize as plain { name, message } data", async () => {
|
||||
@@ -227,17 +265,29 @@ describe("Error values and instanceof", () => {
|
||||
|
||||
describe("array methods: splice, fill, copyWithin, keys/values/entries", () => {
|
||||
test("splice removes in place and returns the removed elements", async () => {
|
||||
expect(await value(`const a = [1,2,3,4]; const removed = a.splice(1, 2); return { removed, a }`)).toEqual({ removed: [2, 3], a: [1, 4] })
|
||||
expect(await value(`const a = [1,2,3,4]; const removed = a.splice(1, 2); return { removed, a }`)).toEqual({
|
||||
removed: [2, 3],
|
||||
a: [1, 4],
|
||||
})
|
||||
})
|
||||
|
||||
test("splice inserts new elements at the cut", async () => {
|
||||
expect(await value(`const a = ["a","d"]; a.splice(1, 0, "b", "c"); return a`)).toEqual(["a", "b", "c", "d"])
|
||||
expect(await value(`const a = [1,2,3]; const removed = a.splice(1, 1, "x"); return { removed, a }`)).toEqual({ removed: [2], a: [1, "x", 3] })
|
||||
expect(await value(`const a = [1,2,3]; const removed = a.splice(1, 1, "x"); return { removed, a }`)).toEqual({
|
||||
removed: [2],
|
||||
a: [1, "x", 3],
|
||||
})
|
||||
})
|
||||
|
||||
test("splice with one argument removes to the end; negative start counts back", async () => {
|
||||
expect(await value(`const a = [1,2,3]; const removed = a.splice(1); return { removed, a }`)).toEqual({ removed: [2, 3], a: [1] })
|
||||
expect(await value(`const a = [1,2,3]; const removed = a.splice(-1); return { removed, a }`)).toEqual({ removed: [3], a: [1, 2] })
|
||||
expect(await value(`const a = [1,2,3]; const removed = a.splice(1); return { removed, a }`)).toEqual({
|
||||
removed: [2, 3],
|
||||
a: [1],
|
||||
})
|
||||
expect(await value(`const a = [1,2,3]; const removed = a.splice(-1); return { removed, a }`)).toEqual({
|
||||
removed: [3],
|
||||
a: [1, 2],
|
||||
})
|
||||
})
|
||||
|
||||
test("splice rejects inserting a container into itself", async () => {
|
||||
@@ -258,11 +308,13 @@ describe("array methods: splice, fill, copyWithin, keys/values/entries", () => {
|
||||
test("keys/values/entries return arrays usable with for...of and spread", async () => {
|
||||
expect(await value(`return [...["x","y","z"].keys()]`)).toEqual([0, 1, 2])
|
||||
expect(await value(`return ["x","y"].values()`)).toEqual(["x", "y"])
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
const out = []
|
||||
for (const [index, item] of ["a","b"].entries()) out.push(index + ":" + item)
|
||||
return out
|
||||
`)).toEqual(["0:a", "1:b"])
|
||||
`),
|
||||
).toEqual(["0:a", "1:b"])
|
||||
expect(await value(`return [...[7].entries()]`)).toEqual([[0, 7]])
|
||||
})
|
||||
})
|
||||
@@ -300,40 +352,33 @@ describe("compound assignment matches its binary operator", () => {
|
||||
}
|
||||
|
||||
test("sandbox Date += concatenates its string form, like d = d + 1", async () => {
|
||||
const result = await pair(
|
||||
`let d = new Date(1000); d += 1; return d`,
|
||||
`let d = new Date(1000); d = d + 1; return d`,
|
||||
)
|
||||
const result = await pair(`let d = new Date(1000); d += 1; return d`, `let d = new Date(1000); d = d + 1; return d`)
|
||||
expect(result).toBe("1970-01-01T00:00:01.000Z1")
|
||||
})
|
||||
|
||||
test("sandbox Date numeric compound ops use its time value", async () => {
|
||||
expect(await pair(
|
||||
`let d = new Date(1000); d -= 400; return d`,
|
||||
`let d = new Date(1000); d = d - 400; return d`,
|
||||
)).toBe(600)
|
||||
expect(await pair(
|
||||
`let d = new Date(1000); d /= 4; return d`,
|
||||
`let d = new Date(1000); d = d / 4; return d`,
|
||||
)).toBe(250)
|
||||
expect(
|
||||
await pair(`let d = new Date(1000); d -= 400; return d`, `let d = new Date(1000); d = d - 400; return d`),
|
||||
).toBe(600)
|
||||
expect(await pair(`let d = new Date(1000); d /= 4; return d`, `let d = new Date(1000); d = d / 4; return d`)).toBe(
|
||||
250,
|
||||
)
|
||||
})
|
||||
|
||||
test("string += object/array matches x = x + obj", async () => {
|
||||
expect(await pair(
|
||||
`let x = "a"; x += { b: 1 }; return x`,
|
||||
`let x = "a"; x = x + { b: 1 }; return x`,
|
||||
)).toBe("a[object Object]")
|
||||
expect(await pair(
|
||||
`let x = "a"; x += [1, 2]; return x`,
|
||||
`let x = "a"; x = x + [1, 2]; return x`,
|
||||
)).toBe("a1,2")
|
||||
expect(await pair(`let x = "a"; x += { b: 1 }; return x`, `let x = "a"; x = x + { b: 1 }; return x`)).toBe(
|
||||
"a[object Object]",
|
||||
)
|
||||
expect(await pair(`let x = "a"; x += [1, 2]; return x`, `let x = "a"; x = x + [1, 2]; return x`)).toBe("a1,2")
|
||||
})
|
||||
|
||||
test("compound assignment through a member target coerces the same way", async () => {
|
||||
expect(await pair(
|
||||
`const o = { s: "t" }; o.s += new Date(0); return o.s`,
|
||||
`const o = { s: "t" }; o.s = o.s + new Date(0); return o.s`,
|
||||
)).toBe("t1970-01-01T00:00:00.000Z")
|
||||
expect(
|
||||
await pair(
|
||||
`const o = { s: "t" }; o.s += new Date(0); return o.s`,
|
||||
`const o = { s: "t" }; o.s = o.s + new Date(0); return o.s`,
|
||||
),
|
||||
).toBe("t1970-01-01T00:00:00.000Z")
|
||||
})
|
||||
|
||||
test("numeric and string compound operators sweep identically to their expansions", async () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ const sleepyTool = (trace: Trace) =>
|
||||
input: Schema.Struct({ id: Schema.Number, ms: Schema.optionalKey(Schema.Number) }),
|
||||
output: Schema.Number,
|
||||
run: ({ id, ms }) =>
|
||||
Effect.gen(function*() {
|
||||
Effect.gen(function* () {
|
||||
trace.starts.push(id)
|
||||
trace.active += 1
|
||||
trace.maxActive = Math.max(trace.maxActive, trace.active)
|
||||
@@ -31,10 +31,14 @@ const sleepyTool = (trace: Trace) =>
|
||||
trace.active -= 1
|
||||
trace.completed += 1
|
||||
return id
|
||||
}).pipe(Effect.onInterrupt(() => Effect.sync(() => {
|
||||
trace.active -= 1
|
||||
trace.interrupted += 1
|
||||
}))),
|
||||
}).pipe(
|
||||
Effect.onInterrupt(() =>
|
||||
Effect.sync(() => {
|
||||
trace.active -= 1
|
||||
trace.interrupted += 1
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
const failingTool = Tool.make({
|
||||
@@ -46,11 +50,13 @@ const failingTool = Tool.make({
|
||||
|
||||
const run = (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}): Promise<ExecuteResult> => {
|
||||
const trace = options.trace ?? makeTrace()
|
||||
return Effect.runPromise(CodeMode.execute({
|
||||
tools: { host: { sleepy: sleepyTool(trace), fail: failingTool } },
|
||||
code,
|
||||
...(options.limits ? { limits: options.limits } : {}),
|
||||
}))
|
||||
return Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
tools: { host: { sleepy: sleepyTool(trace), fail: failingTool } },
|
||||
code,
|
||||
...(options.limits ? { limits: options.limits } : {}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const value = async (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}) => {
|
||||
@@ -121,7 +127,8 @@ describe("first-class promise values", () => {
|
||||
})
|
||||
|
||||
test("an awaited failure is catchable exactly like a synchronous throw", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
const p = tools.host.fail({})
|
||||
try {
|
||||
await p
|
||||
@@ -129,7 +136,8 @@ describe("first-class promise values", () => {
|
||||
} catch (e) {
|
||||
return e.message
|
||||
}
|
||||
`)).toBe("Lookup refused")
|
||||
`),
|
||||
).toBe("Lookup refused")
|
||||
})
|
||||
|
||||
test("a fire-and-forget call completes before the execution ends", async () => {
|
||||
@@ -186,20 +194,24 @@ describe("promises at data boundaries", () => {
|
||||
|
||||
describe("Promise.all over arbitrary arrays", () => {
|
||||
test("mixes promises and plain values, preserving order", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
return await Promise.all([tools.host.sleepy({ id: 1 }), "plain", tools.host.sleepy({ id: 2 }), 42])
|
||||
`)).toEqual([1, "plain", 2, 42])
|
||||
`),
|
||||
).toEqual([1, "plain", 2, 42])
|
||||
})
|
||||
|
||||
test("accepts arrays built beforehand, passed as identifiers, and spread elements", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
const calls = []
|
||||
calls.push(tools.host.sleepy({ id: 1 }))
|
||||
calls.push(7)
|
||||
const more = [tools.host.sleepy({ id: 2 })]
|
||||
const batch = [...calls, ...more, "x"]
|
||||
return await Promise.all(batch)
|
||||
`)).toEqual([1, 7, 2, "x"])
|
||||
`),
|
||||
).toEqual([1, 7, 2, "x"])
|
||||
})
|
||||
|
||||
test("runs items.map tool calls in parallel", async () => {
|
||||
@@ -238,14 +250,16 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||
})
|
||||
|
||||
test("rejects with the first failure, catchable in-program", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
try {
|
||||
await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.fail({})])
|
||||
return "no"
|
||||
} catch (e) {
|
||||
return e.message
|
||||
}
|
||||
`)).toBe("Lookup refused")
|
||||
`),
|
||||
).toBe("Lookup refused")
|
||||
})
|
||||
|
||||
test("a non-collection argument is a clear error", async () => {
|
||||
@@ -264,14 +278,16 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||
|
||||
describe("Promise.allSettled", () => {
|
||||
test("reports fulfilled and rejected outcomes with catch-normalized reasons", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
return await Promise.allSettled([
|
||||
tools.host.sleepy({ id: 5 }),
|
||||
tools.host.fail({}),
|
||||
"plain",
|
||||
Promise.reject(new Error("boom")),
|
||||
])
|
||||
`)).toEqual([
|
||||
`),
|
||||
).toEqual([
|
||||
{ status: "fulfilled", value: 5 },
|
||||
{ status: "rejected", reason: { name: "Error", message: "Lookup refused" } },
|
||||
{ status: "fulfilled", value: "plain" },
|
||||
@@ -306,7 +322,8 @@ describe("Promise.race", () => {
|
||||
})
|
||||
|
||||
test("awaiting an interrupted loser afterwards is a catchable program failure", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
const fast = tools.host.sleepy({ id: 1, ms: 10 })
|
||||
const slow = tools.host.sleepy({ id: 2, ms: 5000 })
|
||||
const winner = await Promise.race([fast, slow])
|
||||
@@ -316,23 +333,31 @@ describe("Promise.race", () => {
|
||||
} catch (e) {
|
||||
return { winner, caught: e.message }
|
||||
}
|
||||
`)).toEqual({ winner: 1, caught: "This tool call was interrupted because another value settled a Promise.race first." })
|
||||
`),
|
||||
).toEqual({
|
||||
winner: 1,
|
||||
caught: "This tool call was interrupted because another value settled a Promise.race first.",
|
||||
})
|
||||
})
|
||||
|
||||
test("a rejection can win the race", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
try {
|
||||
await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 5000 })])
|
||||
return "no"
|
||||
} catch (e) {
|
||||
return e.message
|
||||
}
|
||||
`)).toBe("Lookup refused")
|
||||
`),
|
||||
).toBe("Lookup refused")
|
||||
})
|
||||
|
||||
test("a plain value wins over pending promises", async () => {
|
||||
const trace = makeTrace()
|
||||
expect(await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 5000 }), "immediate"])`, { trace })).toBe("immediate")
|
||||
expect(
|
||||
await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 5000 }), "immediate"])`, { trace }),
|
||||
).toBe("immediate")
|
||||
expect(trace.interrupted).toBe(1)
|
||||
})
|
||||
|
||||
@@ -350,14 +375,16 @@ describe("Promise.resolve / Promise.reject", () => {
|
||||
})
|
||||
|
||||
test("reject produces a promise whose await throws the reason", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
try {
|
||||
await Promise.reject("nope")
|
||||
return "no"
|
||||
} catch (e) {
|
||||
return e
|
||||
}
|
||||
`)).toBe("nope")
|
||||
`),
|
||||
).toBe("nope")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -83,15 +83,9 @@ describe("pretty signature rendering", () => {
|
||||
true,
|
||||
)
|
||||
expect(pretty).toBe(
|
||||
[
|
||||
"{",
|
||||
" /** Search filter */",
|
||||
" filter?: {",
|
||||
" /** Issue state */",
|
||||
" state?: string",
|
||||
" }",
|
||||
"}",
|
||||
].join("\n"),
|
||||
["{", " /** Search filter */", " filter?: {", " /** Issue state */", " state?: string", " }", "}"].join(
|
||||
"\n",
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -119,7 +113,14 @@ describe("pretty signature rendering", () => {
|
||||
expect(pretty).toContain(" /** @deprecated */\n legacy?: string")
|
||||
expect(pretty).toContain(" /** @format uri */\n homepage?: string")
|
||||
expect(pretty).toContain(
|
||||
[" /**", ' * @default ["a","b"]', " * @minItems 2", " * @maxItems 5", " */", " tags?: Array<string>"].join("\n"),
|
||||
[
|
||||
" /**",
|
||||
' * @default ["a","b"]',
|
||||
" * @minItems 2",
|
||||
" * @maxItems 5",
|
||||
" */",
|
||||
" tags?: Array<string>",
|
||||
].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -212,7 +213,11 @@ describe("non-identifier property names render as quoted keys", () => {
|
||||
const tool = Tool.make({
|
||||
description: "Adapter tool with awkward field names",
|
||||
input: rawSchema,
|
||||
output: { type: "object", properties: { "content-type": { type: "string" } }, required: ["content-type"] } as const,
|
||||
output: {
|
||||
type: "object",
|
||||
properties: { "content-type": { type: "string" } },
|
||||
required: ["content-type"],
|
||||
} as const,
|
||||
run: () => Effect.succeed({ "content-type": "text/plain" }),
|
||||
})
|
||||
expect(inputTypeScript(tool)).toContain('"foo-bar"?: string')
|
||||
@@ -269,9 +274,9 @@ describe("pretty signatures in search results", () => {
|
||||
const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } })
|
||||
|
||||
const search = async (query: string) => {
|
||||
const result = await Effect.runPromise(runtime.execute(
|
||||
`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`,
|
||||
))
|
||||
const result = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`),
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) throw new Error("search failed")
|
||||
return result.value as { items: Array<{ path: string; signature: string }>; total: number }
|
||||
|
||||
@@ -40,7 +40,11 @@ describe("Date", () => {
|
||||
})
|
||||
|
||||
test("UTC getters read calendar components", async () => {
|
||||
expect(await value(`const d = new Date("2024-03-05T06:07:08.009Z"); return [d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds()]`)).toEqual([2024, 2, 5, 6, 7, 8, 9])
|
||||
expect(
|
||||
await value(
|
||||
`const d = new Date("2024-03-05T06:07:08.009Z"); return [d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds()]`,
|
||||
),
|
||||
).toEqual([2024, 2, 5, 6, 7, 8, 9])
|
||||
})
|
||||
|
||||
test("invalid dates yield NaN times, guardable in-sandbox", async () => {
|
||||
@@ -49,7 +53,9 @@ describe("Date", () => {
|
||||
})
|
||||
|
||||
test("toISOString on an invalid date is a catchable error", async () => {
|
||||
expect(await value(`try { new Date("garbage").toISOString(); return "no" } catch { return "caught" }`)).toBe("caught")
|
||||
expect(await value(`try { new Date("garbage").toISOString(); return "no" } catch { return "caught" }`)).toBe(
|
||||
"caught",
|
||||
)
|
||||
})
|
||||
|
||||
test("template interpolation renders the ISO form", async () => {
|
||||
@@ -72,14 +78,18 @@ describe("Date", () => {
|
||||
})
|
||||
|
||||
test("sorting dates with a numeric comparator", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
const dates = [new Date(3000), new Date(1000), new Date(2000)]
|
||||
return dates.sort((a, b) => a - b).map((d) => d.getTime())
|
||||
`)).toEqual([1000, 2000, 3000])
|
||||
`),
|
||||
).toEqual([1000, 2000, 3000])
|
||||
})
|
||||
|
||||
test("new Date(year, month, day) accepts component form", async () => {
|
||||
expect(await value(`const d = new Date(2024, 0, 2); return [d.getFullYear(), d.getMonth(), d.getDate()]`)).toEqual([2024, 0, 2])
|
||||
expect(await value(`const d = new Date(2024, 0, 2); return [d.getFullYear(), d.getMonth(), d.getDate()]`)).toEqual([
|
||||
2024, 0, 2,
|
||||
])
|
||||
})
|
||||
|
||||
test("typeof and unknown properties are forgiving", async () => {
|
||||
@@ -95,25 +105,31 @@ describe("RegExp", () => {
|
||||
})
|
||||
|
||||
test("exec exposes captures and index", async () => {
|
||||
expect(await value(`const m = /a(b+)/.exec("xxabbc"); return { full: m[0], group: m[1], index: m.index }`)).toEqual({
|
||||
full: "abb",
|
||||
group: "bb",
|
||||
index: 2,
|
||||
})
|
||||
expect(await value(`const m = /a(b+)/.exec("xxabbc"); return { full: m[0], group: m[1], index: m.index }`)).toEqual(
|
||||
{
|
||||
full: "abb",
|
||||
group: "bb",
|
||||
index: 2,
|
||||
},
|
||||
)
|
||||
expect(await value(`return /a/.exec("zzz")`)).toBeNull()
|
||||
})
|
||||
|
||||
test("named groups read through", async () => {
|
||||
expect(await value(`const m = /(?<word>[a-z]+)-(?<num>\\d+)/.exec("id ab-42"); return m.groups.word + m.groups.num`)).toBe("ab42")
|
||||
expect(
|
||||
await value(`const m = /(?<word>[a-z]+)-(?<num>\\d+)/.exec("id ab-42"); return m.groups.word + m.groups.num`),
|
||||
).toBe("ab42")
|
||||
})
|
||||
|
||||
test("global exec advances lastIndex across calls", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
const r = /\\d+/g
|
||||
const first = r.exec("a1b22c")
|
||||
const second = r.exec("a1b22c")
|
||||
return [first[0], second[0]]
|
||||
`)).toEqual(["1", "22"])
|
||||
`),
|
||||
).toEqual(["1", "22"])
|
||||
})
|
||||
|
||||
test("string match: non-global carries index, global lists all matches", async () => {
|
||||
@@ -194,34 +210,51 @@ describe("RegExp", () => {
|
||||
|
||||
describe("Map", () => {
|
||||
test("get/set/has/size with chaining", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
const m = new Map()
|
||||
m.set("a", 1).set("b", 2)
|
||||
return { a: m.get("a"), b: m.get("b"), has: m.has("a"), miss: m.get("zz") === undefined, size: m.size }
|
||||
`)).toEqual({ a: 1, b: 2, has: true, miss: true, size: 5 - 3 })
|
||||
`),
|
||||
).toEqual({ a: 1, b: 2, has: true, miss: true, size: 5 - 3 })
|
||||
})
|
||||
|
||||
test("object keys use identity", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
const key = { id: 1 }
|
||||
const m = new Map()
|
||||
m.set(key, "hit")
|
||||
return [m.get(key), m.get({ id: 1 }) === undefined]
|
||||
`)).toEqual(["hit", true])
|
||||
`),
|
||||
).toEqual(["hit", true])
|
||||
})
|
||||
|
||||
test("construction from entry pairs and another Map", async () => {
|
||||
expect(await value(`const m = new Map([["a", 1], ["b", 2]]); return m.get("b")`)).toBe(2)
|
||||
expect(await value(`const m = new Map([["a", 1]]); const n = new Map(m); n.set("b", 2); return [n.get("a"), n.get("b"), m.has("b")]`)).toEqual([1, 2, false])
|
||||
expect(
|
||||
await value(
|
||||
`const m = new Map([["a", 1]]); const n = new Map(m); n.set("b", 2); return [n.get("a"), n.get("b"), m.has("b")]`,
|
||||
),
|
||||
).toEqual([1, 2, false])
|
||||
expect((await error(`return new Map("nope")`)).message).toMatch(/\[key, value\] pairs/)
|
||||
expect((await error(`return new Map(["flat"])`)).message).toMatch(/\[key, value\] pairs/)
|
||||
})
|
||||
|
||||
test("keys/values/entries return arrays", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
const m = new Map([["a", 1], ["b", 2]])
|
||||
return { keys: m.keys(), values: m.values(), entries: m.entries() }
|
||||
`)).toEqual({ keys: ["a", "b"], values: [1, 2], entries: [["a", 1], ["b", 2]] })
|
||||
`),
|
||||
).toEqual({
|
||||
keys: ["a", "b"],
|
||||
values: [1, 2],
|
||||
entries: [
|
||||
["a", 1],
|
||||
["b", 2],
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("Object.fromEntries(map) and Array.from(map)", async () => {
|
||||
@@ -230,13 +263,15 @@ describe("Map", () => {
|
||||
})
|
||||
|
||||
test("for...of iterates [key, value] pairs with destructuring", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
const m = new Map([["a", 1], ["b", 2]])
|
||||
let total = 0
|
||||
let names = ""
|
||||
for (const [key, count] of m) { names += key; total += count }
|
||||
return names + total
|
||||
`)).toBe("ab3")
|
||||
`),
|
||||
).toBe("ab3")
|
||||
})
|
||||
|
||||
test("spread produces entry pairs", async () => {
|
||||
@@ -244,32 +279,38 @@ describe("Map", () => {
|
||||
})
|
||||
|
||||
test("forEach passes (value, key)", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
const m = new Map([["a", 1], ["b", 2]])
|
||||
const seen = []
|
||||
m.forEach((count, key) => seen.push(key + count))
|
||||
return seen
|
||||
`)).toEqual(["a1", "b2"])
|
||||
`),
|
||||
).toEqual(["a1", "b2"])
|
||||
})
|
||||
|
||||
test("delete and clear", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
const m = new Map([["a", 1], ["b", 2]])
|
||||
const removed = m.delete("a")
|
||||
const missed = m.delete("zz")
|
||||
const sizeAfterDelete = m.size
|
||||
m.clear()
|
||||
return [removed, missed, sizeAfterDelete, m.size]
|
||||
`)).toEqual([true, false, 1, 0])
|
||||
`),
|
||||
).toEqual([true, false, 1, 0])
|
||||
})
|
||||
|
||||
test("counting idiom: grouped tallies", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
const words = ["a", "b", "a", "c", "a"]
|
||||
const counts = new Map()
|
||||
for (const word of words) counts.set(word, (counts.get(word) ?? 0) + 1)
|
||||
return Object.fromEntries(counts)
|
||||
`)).toEqual({ a: 3, b: 1, c: 1 })
|
||||
`),
|
||||
).toEqual({ a: 3, b: 1, c: 1 })
|
||||
})
|
||||
|
||||
test("maps serialize to {} at the boundary, like JSON", async () => {
|
||||
@@ -286,12 +327,14 @@ describe("Map", () => {
|
||||
|
||||
describe("Set", () => {
|
||||
test("add/has/delete/size with chaining", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
const s = new Set()
|
||||
s.add(1).add(2).add(1)
|
||||
const removed = s.delete(2)
|
||||
return [s.size, s.has(1), s.has(2), removed]
|
||||
`)).toEqual([1, true, false, true])
|
||||
`),
|
||||
).toEqual([1, true, false, true])
|
||||
})
|
||||
|
||||
test("dedupe idiom: [...new Set(items)]", async () => {
|
||||
@@ -308,11 +351,13 @@ describe("Set", () => {
|
||||
})
|
||||
|
||||
test("for...of iterates values", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
let total = 0
|
||||
for (const n of new Set([1, 2, 3])) total += n
|
||||
return total
|
||||
`)).toBe(6)
|
||||
`),
|
||||
).toBe(6)
|
||||
})
|
||||
|
||||
test("sets serialize to {} at the boundary, like JSON", async () => {
|
||||
@@ -338,21 +383,32 @@ describe("stdlib integration", () => {
|
||||
})
|
||||
|
||||
test("dates inside Map values survive in-sandbox reads", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
const m = new Map([["start", new Date(1000)]])
|
||||
return m.get("start").getTime()
|
||||
`)).toBe(1000)
|
||||
`),
|
||||
).toBe(1000)
|
||||
})
|
||||
|
||||
test("instanceof recognizes the stdlib value types", async () => {
|
||||
expect(await value(`return [new Date(0) instanceof Date, /a/ instanceof RegExp, new Map() instanceof Map, new Set() instanceof Set]`)).toEqual([true, true, true, true])
|
||||
expect(await value(`return [[1] instanceof Array, [1] instanceof Object, ({}) instanceof Object, 5 instanceof Object]`)).toEqual([true, true, true, false])
|
||||
expect(
|
||||
await value(
|
||||
`return [new Date(0) instanceof Date, /a/ instanceof RegExp, new Map() instanceof Map, new Set() instanceof Set]`,
|
||||
),
|
||||
).toEqual([true, true, true, true])
|
||||
expect(
|
||||
await value(`return [[1] instanceof Array, [1] instanceof Object, ({}) instanceof Object, 5 instanceof Object]`),
|
||||
).toEqual([true, true, true, false])
|
||||
expect(await value(`return [new Map() instanceof Set, "s" instanceof Date]`)).toEqual([false, false])
|
||||
expect(await value(`const p = Promise.resolve(1); const isPromise = p instanceof Promise; await p; return isPromise`)).toBe(true)
|
||||
expect(
|
||||
await value(`const p = Promise.resolve(1); const isPromise = p instanceof Promise; await p; return isPromise`),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("realistic pipeline: parse, extract with regex, dedupe, count by day", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
const raw = '[{"at":"2024-01-01T05:00:00Z","tag":"a b"},{"at":"2024-01-01T09:00:00Z","tag":"b c"},{"at":"2024-01-02T01:00:00Z","tag":"a"}]'
|
||||
const rows = JSON.parse(raw)
|
||||
const tags = new Set()
|
||||
@@ -363,27 +419,34 @@ describe("stdlib integration", () => {
|
||||
byDay.set(day, (byDay.get(day) ?? 0) + 1)
|
||||
}
|
||||
return { tags: [...tags].sort((a, b) => (a < b ? -1 : 1)), byDay: Object.fromEntries(byDay) }
|
||||
`)).toEqual({ tags: ["a", "b", "c"], byDay: { "2024-01-01": 2, "2024-01-02": 1 } })
|
||||
`),
|
||||
).toEqual({ tags: ["a", "b", "c"], byDay: { "2024-01-01": 2, "2024-01-02": 1 } })
|
||||
})
|
||||
})
|
||||
|
||||
describe("sandbox values at intra-sandbox checkpoints", () => {
|
||||
test("Object.values/entries keep Dates usable", async () => {
|
||||
expect(await value(`return Object.values({ d: new Date(0) })[0].getTime()`)).toBe(0)
|
||||
expect(await value(`const [key, d] = Object.entries({ d: new Date(0) })[0]; return key + ":" + d.getTime()`)).toBe("d:0")
|
||||
expect(await value(`const [key, d] = Object.entries({ d: new Date(0) })[0]; return key + ":" + d.getTime()`)).toBe(
|
||||
"d:0",
|
||||
)
|
||||
})
|
||||
|
||||
test("Object.assign keeps Maps usable", async () => {
|
||||
expect(await value(`const merged = Object.assign({}, { m: new Map([["a", 1]]) }); return merged.m.get("a")`)).toBe(1)
|
||||
expect(await value(`const merged = Object.assign({}, { m: new Map([["a", 1]]) }); return merged.m.get("a")`)).toBe(
|
||||
1,
|
||||
)
|
||||
})
|
||||
|
||||
test("object and array spread keep sandbox values usable", async () => {
|
||||
expect(await value(`
|
||||
expect(
|
||||
await value(`
|
||||
const src = { m: new Map([["a", 1]]) }
|
||||
const copy = { ...src }
|
||||
copy.m.set("b", 2)
|
||||
return [copy.m.get("a"), src.m.get("b")]
|
||||
`)).toEqual([1, 2])
|
||||
`),
|
||||
).toEqual([1, 2])
|
||||
expect(await value(`const list = [new Date(1000)]; const copy = [...list]; return copy[0].getTime()`)).toBe(1000)
|
||||
})
|
||||
|
||||
@@ -404,7 +467,10 @@ describe("sandbox values at intra-sandbox checkpoints", () => {
|
||||
})
|
||||
|
||||
test("the host boundary still serializes JSON forms: results, JSON.stringify, and tool arguments", async () => {
|
||||
expect(await value(`return { d: new Date(0), m: new Map([["a", 1]]) }`)).toEqual({ d: "1970-01-01T00:00:00.000Z", m: {} })
|
||||
expect(await value(`return { d: new Date(0), m: new Map([["a", 1]]) }`)).toEqual({
|
||||
d: "1970-01-01T00:00:00.000Z",
|
||||
m: {},
|
||||
})
|
||||
expect(await value(`return JSON.stringify({ d: new Date(0) })`)).toBe('{"d":"1970-01-01T00:00:00.000Z"}')
|
||||
|
||||
const observed: Array<unknown> = []
|
||||
@@ -417,10 +483,12 @@ describe("sandbox values at intra-sandbox checkpoints", () => {
|
||||
return "ok"
|
||||
}),
|
||||
})
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
tools: { host: { capture } },
|
||||
code: `return await tools.host.capture({ when: new Date(0), tags: new Map([["a", 1]]) })`,
|
||||
}))
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
tools: { host: { capture } },
|
||||
code: `return await tools.host.capture({ when: new Date(0), tags: new Map([["a", 1]]) })`,
|
||||
}),
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
expect(observed).toStrictEqual([{ when: "1970-01-01T00:00:00.000Z", tags: {} }])
|
||||
})
|
||||
|
||||
@@ -43,7 +43,6 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
|
||||
experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"),
|
||||
experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"),
|
||||
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
|
||||
experimentalCodeMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_CODE_MODE"),
|
||||
experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"),
|
||||
experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
|
||||
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { type Tool } from "ai"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
@@ -154,17 +153,19 @@ export interface ServerInstructions {
|
||||
tools: string[]
|
||||
}
|
||||
|
||||
/** An MCP tool in its native shape; consumers adapt it to their own tool format. */
|
||||
export interface McpTool {
|
||||
/** Shared cached definition; consumers must copy rather than mutate it. */
|
||||
readonly def: MCPToolDef
|
||||
readonly client: MCPClient
|
||||
readonly timeout?: number
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly status: () => Effect.Effect<Record<string, Status>>
|
||||
readonly clients: () => Effect.Effect<Record<string, MCPClient>>
|
||||
readonly instructions: () => Effect.Effect<ServerInstructions[]>
|
||||
readonly tools: () => Effect.Effect<Record<string, Tool>>
|
||||
/**
|
||||
* Raw MCP tool definitions keyed identically to {@link tools} (`toolName(client, name)`).
|
||||
* Unlike {@link tools}, these retain the original `inputSchema`/`outputSchema`, which code
|
||||
* mode uses to render tool signatures (including return types) to the model.
|
||||
*/
|
||||
readonly defs: () => Effect.Effect<Record<string, MCPToolDef>>
|
||||
readonly tools: () => Effect.Effect<Record<string, McpTool>>
|
||||
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
|
||||
readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
|
||||
readonly resourceTemplates: (
|
||||
@@ -662,7 +663,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
|
||||
const tools = Effect.fn("MCP.tools")(function* () {
|
||||
const result: Record<string, Tool> = {}
|
||||
const result: Record<string, McpTool> = {}
|
||||
const s = yield* InstanceState.get(state)
|
||||
|
||||
const cfg = yield* cfgSvc.get()
|
||||
@@ -678,21 +679,8 @@ const layer = Layer.effect(
|
||||
continue
|
||||
}
|
||||
const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout)
|
||||
for (const mcpTool of listed) {
|
||||
const key = McpCatalog.toolName(clientName, mcpTool.name)
|
||||
result[key] = McpCatalog.convertTool(mcpTool, client, timeout)
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const defs = Effect.fn("MCP.defs")(function* () {
|
||||
const result: Record<string, MCPToolDef> = {}
|
||||
const s = yield* InstanceState.get(state)
|
||||
for (const [clientName, listed] of Object.entries(s.defs)) {
|
||||
if (s.status[clientName]?.status !== "connected") continue
|
||||
for (const mcpTool of listed) {
|
||||
result[McpCatalog.toolName(clientName, mcpTool.name)] = mcpTool
|
||||
for (const def of listed) {
|
||||
result[McpCatalog.toolName(clientName, def.name)] = { def, client, timeout }
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -1000,7 +988,6 @@ const layer = Layer.effect(
|
||||
clients,
|
||||
instructions,
|
||||
tools,
|
||||
defs,
|
||||
prompts,
|
||||
resources,
|
||||
resourceTemplates,
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { ToolExecutionOptions } from "ai"
|
||||
import { Effect } from "effect"
|
||||
import type { Plugin } from "@/plugin"
|
||||
import type { Tool } from "@/tool/tool"
|
||||
|
||||
/**
|
||||
* The shared middle of every raw MCP tool invocation: plugin `tool.execute.before`
|
||||
* hook → permission ask → dispatch through the ai-sdk tool's execute inside the
|
||||
* `Tool.execute` tracing span → plugin `tool.execute.after` hook. Used by both the
|
||||
* legacy per-tool registration in `SessionTools.resolve` and code-mode child calls,
|
||||
* so MCP tools execute identically on either path.
|
||||
*
|
||||
* Returns the RAW result the ai-sdk execute resolved with — callers own their
|
||||
* shaping edge (model-facing text/attachment shaping + truncation on the legacy
|
||||
* path, `toSandboxResult` for code-mode child calls). The after hook fires here
|
||||
* with that same raw result, which is exactly what the legacy loop always passed
|
||||
* (the raw MCP result, not the shaped `{title, output, metadata}`), so the hook
|
||||
* payload cannot drift between callers.
|
||||
*
|
||||
* `callID` is the hook/span identity — an opaque string nothing parses. Legacy
|
||||
* passes the ai-sdk `toolCallId`; code-mode child calls pass a synthetic
|
||||
* `${parentCallID}/${n}`. `options.toolCallId` is what the ai-sdk execute sees and
|
||||
* stays each caller's existing value. Failure semantics belong to the caller: hook
|
||||
* failures, permission denials, and tool failures all propagate — the legacy path
|
||||
* lets them fail the tool call as before; code mode converts them into catchable
|
||||
* in-program tool errors at its edge.
|
||||
*/
|
||||
export const invoke = Effect.fn("McpInvoke.invoke")(function* <R>(input: {
|
||||
plugin: Plugin.Interface
|
||||
key: string
|
||||
execute: (args: any, options: ToolExecutionOptions) => R | PromiseLike<R>
|
||||
args: any
|
||||
callID: string
|
||||
options: ToolExecutionOptions
|
||||
sessionID: string
|
||||
messageID: string
|
||||
ask: Tool.Context["ask"]
|
||||
}) {
|
||||
yield* input.plugin.trigger(
|
||||
"tool.execute.before",
|
||||
{ tool: input.key, sessionID: input.sessionID, callID: input.callID },
|
||||
{ args: input.args },
|
||||
)
|
||||
const result: R = yield* Effect.gen(function* () {
|
||||
yield* input.ask({ permission: input.key, metadata: {}, patterns: ["*"], always: ["*"] })
|
||||
return yield* Effect.promise(() => Promise.resolve(input.execute(input.args, input.options)))
|
||||
}).pipe(
|
||||
Effect.withSpan("Tool.execute", {
|
||||
attributes: {
|
||||
"tool.name": input.key,
|
||||
"tool.call_id": input.callID,
|
||||
"session.id": input.sessionID,
|
||||
"message.id": input.messageID,
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* input.plugin.trigger(
|
||||
"tool.execute.after",
|
||||
{ tool: input.key, sessionID: input.sessionID, callID: input.callID, args: input.args },
|
||||
result,
|
||||
)
|
||||
return result
|
||||
})
|
||||
|
||||
export * as McpInvoke from "./invoke"
|
||||
@@ -213,13 +213,6 @@ export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set<st
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared tool-visibility predicate: drop every tool a hard deny hides
|
||||
* ({@link disabled} semantics — a matching `deny` rule with pattern `"*"`).
|
||||
* Ask-level rules leave a tool fully visible and callable (it prompts at call
|
||||
* time). Used both when preparing the LLM tool list (request prep) and when
|
||||
* building/dispatching the code-mode MCP catalog, so the two cannot drift.
|
||||
*/
|
||||
export function visibleTools<T>(tools: Record<string, T>, ruleset: PermissionV1.Ruleset): Record<string, T> {
|
||||
const hidden = disabled(Object.keys(tools), ruleset)
|
||||
return Object.fromEntries(Object.entries(tools).filter(([name]) => !hidden.has(name)))
|
||||
|
||||
@@ -206,11 +206,11 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre
|
||||
})
|
||||
|
||||
function resolveTools(input: Pick<PrepareInput, "tools" | "agent" | "permission" | "user">) {
|
||||
const visible = Permission.visibleTools(
|
||||
input.tools,
|
||||
const disabled = Permission.disabled(
|
||||
Object.keys(input.tools),
|
||||
Permission.merge(input.agent.permission, input.permission ?? []),
|
||||
)
|
||||
return Record.filter(visible, (_, k) => input.user.tools?.[k] !== false)
|
||||
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
|
||||
}
|
||||
|
||||
export function hasToolCalls(messages: ModelMessage[]): boolean {
|
||||
|
||||
@@ -1237,8 +1237,6 @@ const layer = Layer.effect(
|
||||
Effect.provideService(ToolRegistry.Service, registry),
|
||||
Effect.provideService(MCP.Service, mcp),
|
||||
Effect.provideService(Truncate.Service, truncate),
|
||||
Effect.provideService(Agent.Service, agents),
|
||||
Effect.provideService(RuntimeFlags.Service, flags),
|
||||
)
|
||||
|
||||
if (lastUser.format?.type === "json_schema") {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { MCP } from "@/mcp"
|
||||
import { McpInvoke } from "@/mcp/invoke"
|
||||
import { McpCatalog } from "@/mcp/catalog"
|
||||
import { Permission } from "@/permission"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import { ToolJsonSchema } from "@/tool/json-schema"
|
||||
@@ -22,7 +22,6 @@ import { EffectBridge } from "@/effect/bridge"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
|
||||
const MCP_RESOURCE_TOOLS = {
|
||||
list: "list_mcp_resources",
|
||||
@@ -54,7 +53,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const truncate = yield* Truncate.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
|
||||
const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({
|
||||
sessionID: input.session.id,
|
||||
@@ -89,20 +87,11 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
.pipe(Effect.orDie),
|
||||
})
|
||||
|
||||
const mcpTools = yield* mcp.tools()
|
||||
// When code mode is enabled and MCP tools are present, the registry exposes them
|
||||
// through the single code-mode `execute` tool (ToolRegistry.tools), so raw per-MCP
|
||||
// registration is suppressed via the early return below. Code mode is experimental
|
||||
// and off by default.
|
||||
const codeMode = flags.experimentalCodeMode && Object.keys(mcpTools).length > 0
|
||||
const registryTools = yield* registry.tools({
|
||||
for (const item of yield* registry.tools({
|
||||
modelID: ModelV2.ID.make(input.model.api.id),
|
||||
providerID: input.model.providerID,
|
||||
agent: input.agent,
|
||||
permission: input.session.permission,
|
||||
})
|
||||
|
||||
for (const item of registryTools) {
|
||||
})) {
|
||||
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
|
||||
tools[item.id] = tool({
|
||||
description: item.description,
|
||||
@@ -393,9 +382,8 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
})
|
||||
}
|
||||
|
||||
if (codeMode) return tools
|
||||
|
||||
for (const [key, item] of Object.entries(mcpTools)) {
|
||||
for (const [key, entry] of Object.entries(yield* mcp.tools())) {
|
||||
const item = McpCatalog.convertTool(entry.def, entry.client, entry.timeout)
|
||||
const execute = item.execute
|
||||
if (!execute) continue
|
||||
|
||||
@@ -406,19 +394,29 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
run.promise(
|
||||
Effect.gen(function* () {
|
||||
const ctx = context(args, opts)
|
||||
// Shared MCP middle (before hook → permission ask → Tool.execute span →
|
||||
// dispatch → after hook); this caller keeps the model-facing shaping edge below.
|
||||
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* McpInvoke.invoke({
|
||||
plugin,
|
||||
key,
|
||||
execute,
|
||||
args,
|
||||
callID: opts.toolCallId,
|
||||
options: opts,
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: input.processor.message.id,
|
||||
ask: ctx.ask,
|
||||
})
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.before",
|
||||
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
||||
{ args },
|
||||
)
|
||||
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () {
|
||||
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
|
||||
return yield* Effect.promise(() => execute(args, opts))
|
||||
}).pipe(
|
||||
Effect.withSpan("Tool.execute", {
|
||||
attributes: {
|
||||
"tool.name": key,
|
||||
"tool.call_id": opts.toolCallId,
|
||||
"session.id": ctx.sessionID,
|
||||
"message.id": input.processor.message.id,
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.after",
|
||||
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
||||
result,
|
||||
)
|
||||
|
||||
const textParts: string[] = []
|
||||
const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = []
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as Tool from "./tool"
|
||||
import type { Tool as AITool } from "ai"
|
||||
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { CallToolResultSchema, type CallToolResult } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Cause, Effect, Schema } from "effect"
|
||||
import {
|
||||
CodeMode,
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
} from "@opencode-ai/codemode"
|
||||
import { MCP } from "@/mcp"
|
||||
import { McpCatalog } from "@/mcp/catalog"
|
||||
import { McpInvoke } from "@/mcp/invoke"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Session } from "@/session/session"
|
||||
import { Permission } from "@/permission"
|
||||
@@ -20,18 +18,6 @@ import { Plugin } from "@/plugin"
|
||||
|
||||
export const CODE_MODE_TOOL = "execute"
|
||||
|
||||
// OpenCode sets NO execution limits: no timeout, no tool-call cap, and no CodeMode output
|
||||
// truncation. Cancelling the tool call aborts `ctx.abort`, which wins the race below and
|
||||
// interrupts the execution fiber — structured concurrency takes the program and its
|
||||
// in-flight child calls down with it; every child call is permission-gated anyway. Output
|
||||
// bounding is OpenCode's native tool-output truncation (Tool.define's shared wrapper),
|
||||
// which applies to `execute` like any other tool and dumps the full output to a file when
|
||||
// it triggers.
|
||||
|
||||
// The static base description. The full usage guide and the grouped, permission-filtered
|
||||
// tool catalog are appended per agent by the registry (`describeCodeMode`, the same
|
||||
// composition point `describeTask` uses), so `plugin.trigger("tool.definition")` sees this
|
||||
// base first, exactly like the task tool.
|
||||
const DESCRIPTION = [
|
||||
"Execute a JavaScript/TypeScript program that orchestrates the connected MCP tools inside a confined runtime.",
|
||||
"The full usage guide and the catalog of available tools follow below.",
|
||||
@@ -47,119 +33,51 @@ export const Parameters = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
|
||||
/** One child tool call, surfaced live so the UI can render a per-call line that
|
||||
* updates as the program runs. `tool` is the dotted path (e.g. `github.create_issue`). */
|
||||
export type CallEntry = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }
|
||||
type CallEntry = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }
|
||||
|
||||
type Metadata = {
|
||||
toolCalls: CallEntry[]
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool-result attachment: identical to a session `FilePart` (minus the ids) and
|
||||
* carrying the actual bytes (`url`, often a base64 `data:` URL), so it lowers 1:1 into
|
||||
* `Tool.ExecuteResult.attachments`. Attachments never enter the sandbox — media stripped
|
||||
* from child tool results is accumulated host-side and returned on the outer `execute`
|
||||
* result, where the existing attachment plumbing turns it into visible images/files.
|
||||
*/
|
||||
export type Attachment = NonNullable<Tool.ExecuteResult["attachments"]>[number]
|
||||
type Attachment = NonNullable<Tool.ExecuteResult["attachments"]>[number]
|
||||
|
||||
/** One MCP tool in the grouped catalog: the flat `server_tool` key split into its
|
||||
* namespace (`server`) and local name, with the raw JSON Schemas used for rendering. */
|
||||
export type CatalogEntry = {
|
||||
type CatalogEntry = {
|
||||
path: string
|
||||
key: string
|
||||
server: string
|
||||
local: string
|
||||
description: string
|
||||
tool: AITool
|
||||
inputSchema: JsonSchema
|
||||
outputSchema?: JsonSchema
|
||||
tool: MCP.McpTool
|
||||
}
|
||||
|
||||
/** Render-only cast: MCP definitions carry JSON Schema documents already. */
|
||||
const toJsonSchema = (schema: unknown): JsonSchema => schema as JsonSchema
|
||||
|
||||
/** The input schema for entries without a cached MCP definition, recovered from the
|
||||
* ai-sdk tool when possible so signatures stay informative. */
|
||||
function fallbackInputSchema(tool: AITool): JsonSchema {
|
||||
const schema = (tool.inputSchema as { jsonSchema?: unknown } | undefined)?.jsonSchema
|
||||
if (schema && typeof schema === "object") return toJsonSchema(schema)
|
||||
return { type: "object", properties: {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* Group the flat `server_tool` catalog into per-server namespaces. `servers` are
|
||||
* the sanitized MCP client names; the longest matching prefix wins so a server
|
||||
* named `a_b` beats `a` for the key `a_b_tool`. `mcpDefs` carries the raw MCP
|
||||
* definitions (keyed identically) so each entry retains its original
|
||||
* `inputSchema`/`outputSchema` for signature rendering.
|
||||
*/
|
||||
export function groupByServer(
|
||||
mcpTools: Record<string, AITool>,
|
||||
servers: readonly string[],
|
||||
mcpDefs: Record<string, MCPToolDef> = {},
|
||||
): Map<string, CatalogEntry[]> {
|
||||
function groupByServer(mcpTools: Record<string, MCP.McpTool>, servers: readonly string[]): Map<string, CatalogEntry[]> {
|
||||
const byLongest = [...servers].sort((a, b) => b.length - a.length)
|
||||
const groups = new Map<string, CatalogEntry[]>()
|
||||
for (const key of Object.keys(mcpTools).sort((a, b) => a.localeCompare(b))) {
|
||||
const server = byLongest.find((name) => key.startsWith(name + "_")) ?? (key.includes("_") ? key.slice(0, key.indexOf("_")) : key)
|
||||
const server =
|
||||
byLongest.find((name) => key.startsWith(name + "_")) ?? (key.includes("_") ? key.slice(0, key.indexOf("_")) : key)
|
||||
const local = server && key.startsWith(server + "_") ? key.slice(server.length + 1) : key
|
||||
const def = mcpDefs[key]
|
||||
const entry: CatalogEntry = {
|
||||
path: `${server}.${local}`,
|
||||
key,
|
||||
server,
|
||||
local,
|
||||
description: mcpTools[key]!.description ?? def?.description ?? "",
|
||||
tool: mcpTools[key]!,
|
||||
inputSchema: def?.inputSchema ? toJsonSchema(def.inputSchema) : fallbackInputSchema(mcpTools[key]!),
|
||||
...(def?.outputSchema ? { outputSchema: toJsonSchema(def.outputSchema) } : {}),
|
||||
}
|
||||
groups.set(server, [...(groups.get(server) ?? []), entry])
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/** The executable catalog for a (already permission-filtered) MCP tool set: grouped
|
||||
* entries, minus any without an ai-sdk execute function. */
|
||||
export function buildCatalog(
|
||||
mcpTools: Record<string, AITool>,
|
||||
mcpDefs: Record<string, MCPToolDef>,
|
||||
servers: readonly string[],
|
||||
): CatalogEntry[] {
|
||||
return [...groupByServer(mcpTools, servers, mcpDefs).values()].flat().filter((entry) => entry.tool.execute !== undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* The model-facing usage guide plus grouped catalog for the given MCP tool set: the
|
||||
* CodeMode instructions for this tool tree (syntax guide + tool signatures, or the
|
||||
* namespace overview + search for large catalogs). Callers pass an already
|
||||
* permission-filtered tool set — hard-denied tools never enter the catalog. The preview
|
||||
* tree's runs are placeholders — rendering never invokes them.
|
||||
*/
|
||||
export function catalogInstructions(
|
||||
mcpTools: Record<string, AITool>,
|
||||
mcpDefs: Record<string, MCPToolDef>,
|
||||
servers: readonly string[],
|
||||
): string {
|
||||
const catalog = buildCatalog(mcpTools, mcpDefs, servers)
|
||||
export function describeCatalog(mcpTools: Record<string, MCP.McpTool>, servers: readonly string[]): string {
|
||||
return CodeMode.make({
|
||||
tools: toolTree(catalog, () => () => Effect.fail(toolError("Tool preview is not executable."))),
|
||||
tools: toolTree(
|
||||
[...groupByServer(mcpTools, servers).values()].flat(),
|
||||
() => () => Effect.fail(toolError("Tool preview is not executable.")),
|
||||
),
|
||||
}).instructions()
|
||||
}
|
||||
|
||||
function displayInput(input: unknown): Record<string, unknown> | undefined {
|
||||
if (input === null || input === undefined) return
|
||||
if (typeof input === "object" && !Array.isArray(input)) {
|
||||
const value = input as Record<string, unknown>
|
||||
if (Object.keys(value).length > 0) return value
|
||||
return
|
||||
}
|
||||
return { input }
|
||||
}
|
||||
|
||||
const lastSegment = (uri: string) => {
|
||||
const trimmed = uri.split(/[?#]/, 1)[0]!.replace(/\/+$/, "")
|
||||
const segment = trimmed.slice(trimmed.lastIndexOf("/") + 1)
|
||||
@@ -168,24 +86,7 @@ const lastSegment = (uri: string) => {
|
||||
|
||||
const dataUrl = (mime: string, base64: string) => `data:${mime};base64,${base64}`
|
||||
|
||||
/** The stand-in payload for a media-only tool result, so the program knows the call
|
||||
* succeeded even though the media itself never enters the sandbox. */
|
||||
const mediaMarker = (files: number, images: number) => {
|
||||
const noun = files === images ? "image" : "file"
|
||||
return `[${files} ${noun}${files === 1 ? "" : "s"} attached to the result]`
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce a raw MCP tool result to the value the sandbox sees. Structured content is
|
||||
* preferred; otherwise text blocks are joined. Media blocks (image/audio/resource
|
||||
* blob/resource_link) NEVER enter the sandbox: they are stripped into `collect`, the
|
||||
* per-execution attachment accumulator, and a tool that returned ONLY media yields a
|
||||
* small text marker instead. Lenient — never throws on unexpected shapes.
|
||||
*/
|
||||
export function toSandboxResult(raw: unknown, collect: (attachment: Attachment) => void): unknown {
|
||||
if (raw === null || typeof raw !== "object") return raw
|
||||
const record = raw as { structuredContent?: unknown; content?: unknown }
|
||||
const content = Array.isArray(record.content) ? record.content : []
|
||||
function projectMcpResult(result: CallToolResult, collect: (attachment: Attachment) => void): unknown {
|
||||
const text: string[] = []
|
||||
let files = 0
|
||||
let images = 0
|
||||
@@ -194,105 +95,109 @@ export function toSandboxResult(raw: unknown, collect: (attachment: Attachment)
|
||||
if (attachment.mime.startsWith("image/")) images += 1
|
||||
collect(attachment)
|
||||
}
|
||||
for (const item of content) {
|
||||
if (!item || typeof item !== "object") continue
|
||||
const block = item as Record<string, unknown>
|
||||
for (const block of result.content) {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
if (typeof block.text === "string") text.push(block.text)
|
||||
text.push(block.text)
|
||||
break
|
||||
case "image":
|
||||
case "audio":
|
||||
if (typeof block.data === "string" && typeof block.mimeType === "string") {
|
||||
push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) })
|
||||
}
|
||||
push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) })
|
||||
break
|
||||
case "resource": {
|
||||
const res = block.resource as Record<string, unknown> | undefined
|
||||
if (res && typeof res === "object") {
|
||||
const mime = typeof res.mimeType === "string" ? res.mimeType : "application/octet-stream"
|
||||
const uri = typeof res.uri === "string" ? res.uri : undefined
|
||||
if (typeof res.blob === "string") {
|
||||
push({ type: "file", mime, url: dataUrl(mime, res.blob), filename: uri ? lastSegment(uri) : undefined })
|
||||
} else if (typeof res.text === "string") {
|
||||
text.push(res.text)
|
||||
}
|
||||
if ("text" in block.resource) {
|
||||
text.push(block.resource.text)
|
||||
break
|
||||
}
|
||||
const mime = block.resource.mimeType ?? "application/octet-stream"
|
||||
push({ type: "file", mime, url: dataUrl(mime, block.resource.blob), filename: lastSegment(block.resource.uri) })
|
||||
break
|
||||
}
|
||||
case "resource_link":
|
||||
if (typeof block.uri === "string") {
|
||||
push({
|
||||
type: "file",
|
||||
mime: typeof block.mimeType === "string" ? block.mimeType : "application/octet-stream",
|
||||
url: block.uri,
|
||||
filename: typeof block.name === "string" ? block.name : lastSegment(block.uri),
|
||||
})
|
||||
}
|
||||
// A link is a reference, not fetchable media; hand it to the program instead of the attachment channel.
|
||||
text.push(`${block.name}: ${block.uri}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (record.structuredContent !== undefined && record.structuredContent !== null) return record.structuredContent
|
||||
if (result.structuredContent !== undefined && result.structuredContent !== null) return result.structuredContent
|
||||
if (text.length > 0) return text.join("\n")
|
||||
if (files > 0) return mediaMarker(files, images)
|
||||
if (Array.isArray(record.content)) return null // MCP-shaped result with nothing extractable
|
||||
return raw
|
||||
}
|
||||
|
||||
/**
|
||||
* Append captured `console.*` output to the model-facing text as a trailing `Logs:` section,
|
||||
* so a program's diagnostics ride back alongside its result — on success AND on error.
|
||||
* Returns the text unchanged when nothing was logged. This is the sandbox's only
|
||||
* stdout-like channel — it goes to the model, not the user.
|
||||
*/
|
||||
export function withLogs(output: string, logs: ReadonlyArray<string> = []): string {
|
||||
if (logs.length === 0) return output
|
||||
const section = "Logs:\n" + logs.join("\n")
|
||||
return output.length > 0 ? `${output}\n\n${section}` : section
|
||||
}
|
||||
|
||||
/** Coerce the program's return value to model-facing text without ever failing on shape. */
|
||||
export function formatValue(value: unknown): string {
|
||||
if (typeof value === "string") return value
|
||||
if (value === undefined) return "undefined"
|
||||
try {
|
||||
return JSON.stringify(value, null, 2) ?? String(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
if (files > 0) {
|
||||
const noun = files === images ? "image" : "file"
|
||||
return `[${files} ${noun}${files === 1 ? "" : "s"} attached to the result]`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type Run = (input: unknown) => Effect.Effect<unknown, unknown>
|
||||
|
||||
/** Build the `tools.<server>.<tool>` tree CodeMode executes against, one
|
||||
* `Tool.make` definition per MCP tool with its render-only JSON Schemas. */
|
||||
function toolTree(catalog: readonly CatalogEntry[], run: (entry: CatalogEntry) => Run) {
|
||||
const tree: Record<string, Record<string, ToolDefinition>> = {}
|
||||
for (const entry of catalog) {
|
||||
const namespace = (tree[entry.server] ??= {})
|
||||
namespace[entry.local] = SandboxTool.make({
|
||||
description: entry.description,
|
||||
input: entry.inputSchema,
|
||||
output: entry.outputSchema,
|
||||
description: entry.tool.def.description ?? "",
|
||||
input: entry.tool.def.inputSchema as JsonSchema,
|
||||
output: entry.tool.def.outputSchema as JsonSchema | undefined,
|
||||
run: run(entry),
|
||||
})
|
||||
}
|
||||
return tree
|
||||
}
|
||||
|
||||
/** Failures inside a child call — plugin hook failures, permission denials, and tool
|
||||
* failures alike — become safe, catchable in-program errors via toolError, so a
|
||||
* program can try/catch one call without the whole execution dying. Interruption
|
||||
* (user cancel) keeps propagating as interruption. */
|
||||
const toCatchable = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
|
||||
const error = Cause.squash(cause)
|
||||
return Effect.fail(toolError(error instanceof Error ? error.message : String(error), error))
|
||||
const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* (input: {
|
||||
plugin: Plugin.Interface
|
||||
entry: CatalogEntry
|
||||
args: Record<string, unknown>
|
||||
callID: string
|
||||
ctx: Tool.Context
|
||||
}) {
|
||||
yield* input.plugin.trigger(
|
||||
"tool.execute.before",
|
||||
{ tool: input.entry.key, sessionID: input.ctx.sessionID, callID: input.callID },
|
||||
{ args: input.args },
|
||||
)
|
||||
const result: CallToolResult = yield* Effect.gen(function* () {
|
||||
yield* input.ctx.ask({ permission: input.entry.key, metadata: {}, patterns: ["*"], always: ["*"] })
|
||||
// Deliberately mirrors McpCatalog.convertTool's transport call so the MCP service stays free of tool-loop concerns.
|
||||
return yield* Effect.promise(async () => {
|
||||
const raw = await input.entry.tool.client.callTool(
|
||||
{ name: input.entry.tool.def.name, arguments: input.args },
|
||||
CallToolResultSchema,
|
||||
{
|
||||
resetTimeoutOnProgress: true,
|
||||
signal: input.ctx.abort,
|
||||
timeout: input.entry.tool.timeout,
|
||||
// The MCP SDK only sends a progress token when this hook is present, enabling timeout resets.
|
||||
onprogress: () => {},
|
||||
},
|
||||
)
|
||||
if (raw.isError)
|
||||
throw new Error(
|
||||
raw.content
|
||||
.flatMap((item) => (item.type === "text" ? [item.text] : []))
|
||||
.filter((text) => text.trim())
|
||||
.join("\n\n") || "MCP tool returned an error",
|
||||
)
|
||||
return raw
|
||||
})
|
||||
}).pipe(
|
||||
Effect.withSpan("Tool.execute", {
|
||||
attributes: {
|
||||
"tool.name": input.entry.key,
|
||||
"tool.call_id": input.callID,
|
||||
"session.id": input.ctx.sessionID,
|
||||
"message.id": input.ctx.messageID,
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* input.plugin.trigger(
|
||||
"tool.execute.after",
|
||||
{ tool: input.entry.key, sessionID: input.ctx.sessionID, callID: input.callID, args: input.args },
|
||||
result,
|
||||
)
|
||||
return result
|
||||
})
|
||||
|
||||
export const CodeModeTool = Tool.define(
|
||||
CODE_MODE_TOOL,
|
||||
@@ -306,8 +211,6 @@ export const CodeModeTool = Tool.define(
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
execute: Effect.fn("CodeMode.execute")(function* (params, ctx) {
|
||||
// Already cancelled: don't start the program at all. (The mid-flight case is the
|
||||
// race below; racing alone would still let the program run its first steps.)
|
||||
if (ctx.abort.aborted) {
|
||||
return {
|
||||
title: CODE_MODE_TOOL,
|
||||
@@ -315,53 +218,35 @@ export const CodeModeTool = Tool.define(
|
||||
output: "Execution cancelled.",
|
||||
} satisfies Tool.ExecuteResult<Metadata>
|
||||
}
|
||||
// A fresh MCP snapshot per execution, so the runtime tracks live tool-list
|
||||
// changes, filtered with the same merged agent+session ruleset that gates
|
||||
// `ctx.ask` (see SessionTools.context). A hard-denied tool never enters the
|
||||
// tree, so it is not dispatchable even if the model guesses its name — the
|
||||
// program gets the normal unknown-tool diagnostic, not a permission error.
|
||||
const agent = yield* agents.get(ctx.agent)
|
||||
const session = yield* sessions.get(ctx.sessionID).pipe(Effect.orDie)
|
||||
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
|
||||
const mcpTools = Permission.visibleTools(yield* mcp.tools(), ruleset)
|
||||
const servers = Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize)
|
||||
const catalog = buildCatalog(mcpTools, yield* mcp.defs(), servers)
|
||||
const catalog = [...groupByServer(mcpTools, servers).values()].flat()
|
||||
|
||||
const calls: CallEntry[] = []
|
||||
// Media stripped from child tool results accumulates here for the life of the
|
||||
// call; the bytes never enter the sandbox (see toSandboxResult).
|
||||
const attachments: Attachment[] = []
|
||||
const collect = (attachment: Attachment) => void attachments.push(attachment)
|
||||
// Stream the current call list to the UI. Sent on every status change so the
|
||||
// tool part shows each child call appearing and resolving while the program runs.
|
||||
const publish = () => ctx.metadata({ title: CODE_MODE_TOOL, metadata: { toolCalls: calls.map((c) => ({ ...c })) } })
|
||||
const publish = () =>
|
||||
ctx.metadata({ title: CODE_MODE_TOOL, metadata: { toolCalls: calls.map((c) => ({ ...c })) } })
|
||||
|
||||
// One CodeMode tool per MCP tool, running the same shared middle as legacy
|
||||
// per-tool registration (McpInvoke.invoke: plugin before hook → permission
|
||||
// ask → Tool.execute span → dispatch through the ai-sdk wrapper, which owns
|
||||
// callTool timeouts/progress and turns an MCP isError into a thrown Error →
|
||||
// plugin after hook), so plugins observe child calls too. Each child gets a
|
||||
// synthetic hook/span callID `${parentCallID}/${n}` (per-execution counter,
|
||||
// opaque — nothing parses it); the ai-sdk toolCallId is unchanged. Failures —
|
||||
// hook, denial, or tool — fail only that child call as a safe, catchable
|
||||
// in-program error (toCatchable); the raw result is then shaped for the sandbox.
|
||||
let childCalls = 0
|
||||
const callTool = (entry: CatalogEntry) => (input: unknown) =>
|
||||
toCatchable(
|
||||
Effect.gen(function* () {
|
||||
childCalls += 1
|
||||
const raw = yield* McpInvoke.invoke({
|
||||
plugin,
|
||||
key: entry.key,
|
||||
execute: entry.tool.execute!,
|
||||
args: input ?? {},
|
||||
callID: `${ctx.callID ?? entry.key}/${childCalls}`,
|
||||
options: { toolCallId: ctx.callID ?? entry.key, abortSignal: ctx.abort, messages: [] },
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: ctx.messageID,
|
||||
ask: ctx.ask,
|
||||
})
|
||||
return toSandboxResult(raw, collect)
|
||||
Effect.gen(function* () {
|
||||
childCalls += 1
|
||||
const result = yield* invokeChildTool({
|
||||
plugin,
|
||||
entry,
|
||||
args: (input ?? {}) as Record<string, unknown>,
|
||||
callID: `${ctx.callID ?? entry.key}/${childCalls}`,
|
||||
ctx,
|
||||
})
|
||||
return projectMcpResult(result, (attachment: Attachment) => void attachments.push(attachment))
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
|
||||
const error = Cause.squash(cause)
|
||||
return Effect.fail(toolError(error instanceof Error ? error.message : String(error), error))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -369,7 +254,14 @@ export const CodeModeTool = Tool.define(
|
||||
tools: toolTree(catalog, callTool),
|
||||
onToolCallStart: ({ index, name, input }) =>
|
||||
Effect.suspend(() => {
|
||||
const shown = displayInput(input)
|
||||
const shown = (() => {
|
||||
if (input === null || input === undefined) return
|
||||
if (typeof input === "object" && !Array.isArray(input)) {
|
||||
const value = input as Record<string, unknown>
|
||||
return Object.keys(value).length > 0 ? value : undefined
|
||||
}
|
||||
return { input }
|
||||
})()
|
||||
calls[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
|
||||
return publish()
|
||||
}),
|
||||
@@ -381,44 +273,46 @@ export const CodeModeTool = Tool.define(
|
||||
}),
|
||||
})
|
||||
|
||||
// The shared tool runner does not wire ctx.abort to fiber interruption (it runs
|
||||
// tools via Effect.runPromise with no abort handling), so without this race the
|
||||
// program would keep running after the user cancels. The abort signal winning the
|
||||
// race interrupts the execution fiber; the cancelled result keeps the runner's
|
||||
// post-abort bookkeeping (completeToolCall) on its normal path.
|
||||
const cancelled = Effect.callback<ExecuteResult>((resume) => {
|
||||
const onAbort = () =>
|
||||
resume(
|
||||
Effect.succeed<ExecuteResult>({
|
||||
ok: false,
|
||||
error: { kind: "ExecutionFailure", message: "Execution cancelled." },
|
||||
toolCalls: calls.map((call) => ({ name: call.tool })),
|
||||
}),
|
||||
)
|
||||
if (ctx.abort.aborted) return onAbort()
|
||||
ctx.abort.addEventListener("abort", onAbort, { once: true })
|
||||
return Effect.sync(() => ctx.abort.removeEventListener("abort", onAbort))
|
||||
const abort = Effect.callback<void>((resume) => {
|
||||
if (ctx.abort.aborted) return resume(Effect.void)
|
||||
const handler = () => resume(Effect.void)
|
||||
ctx.abort.addEventListener("abort", handler, { once: true })
|
||||
return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))
|
||||
})
|
||||
const cancelled = (): ExecuteResult => ({
|
||||
ok: false,
|
||||
error: { kind: "ExecutionFailure", message: "Execution cancelled." },
|
||||
toolCalls: calls.map((call) => ({ name: call.tool })),
|
||||
})
|
||||
|
||||
const result = yield* Effect.raceFirst(runtime.execute(params.code), cancelled)
|
||||
const result = yield* Effect.raceFirst(runtime.execute(params.code), abort.pipe(Effect.map(cancelled)))
|
||||
const logs = result.logs ?? []
|
||||
const attached = attachments.length > 0 ? { attachments } : {}
|
||||
|
||||
const hints = result.ok
|
||||
? []
|
||||
: (result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))
|
||||
const metadata: Metadata = result.ok ? { toolCalls: calls } : { toolCalls: calls, error: true }
|
||||
let output: string
|
||||
if (result.ok) {
|
||||
return {
|
||||
title: CODE_MODE_TOOL,
|
||||
metadata: { toolCalls: calls },
|
||||
output: withLogs(formatValue(result.value), logs),
|
||||
...attached,
|
||||
} satisfies Tool.ExecuteResult<Metadata>
|
||||
if (typeof result.value === "string") output = result.value
|
||||
else if (result.value === undefined) output = "undefined"
|
||||
else {
|
||||
try {
|
||||
output = JSON.stringify(result.value, null, 2) ?? String(result.value)
|
||||
} catch {
|
||||
output = String(result.value)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output = [result.error.message, ...hints].join("\n")
|
||||
}
|
||||
// Diagnostics may carry suggestions (e.g. pointing an unknown tool at
|
||||
// discovery); append the ones the message doesn't already contain.
|
||||
const hints = (result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))
|
||||
if (logs.length > 0)
|
||||
output = output.length > 0 ? `${output}\n\nLogs:\n${logs.join("\n")}` : `Logs:\n${logs.join("\n")}`
|
||||
|
||||
return {
|
||||
title: CODE_MODE_TOOL,
|
||||
metadata: { toolCalls: calls, error: true },
|
||||
output: withLogs([result.error.message, ...hints].join("\n"), logs),
|
||||
metadata,
|
||||
output,
|
||||
...attached,
|
||||
} satisfies Tool.ExecuteResult<Metadata>
|
||||
}),
|
||||
|
||||
@@ -26,9 +26,6 @@ import { Plugin } from "../plugin"
|
||||
import { Provider } from "@/provider/provider"
|
||||
|
||||
import { WebSearchTool } from "./websearch"
|
||||
import { CodeModeTool, catalogInstructions } from "./code-mode"
|
||||
import { MCP } from "@/mcp"
|
||||
import { McpCatalog } from "@/mcp/catalog"
|
||||
import { LspTool } from "./lsp"
|
||||
import * as Truncate from "./truncate"
|
||||
import { ApplyPatchTool } from "./apply_patch"
|
||||
@@ -54,7 +51,6 @@ import { BackgroundJob } from "@/background/job"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||
|
||||
export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) {
|
||||
return providerID === ProviderV2.ID.opencode || flags.exa || flags.parallel
|
||||
@@ -78,7 +74,6 @@ export interface Interface {
|
||||
providerID: ProviderV2.ID
|
||||
modelID: ModelV2.ID
|
||||
agent: Agent.Info
|
||||
permission?: PermissionV1.Ruleset
|
||||
}) => Effect.Effect<Tool.Def[]>
|
||||
}
|
||||
|
||||
@@ -92,7 +87,6 @@ const layer = Layer.effect(
|
||||
const agents = yield* Agent.Service
|
||||
const truncate = yield* Truncate.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const mcp = yield* MCP.Service
|
||||
|
||||
const invalid = yield* InvalidTool
|
||||
const task = yield* TaskTool
|
||||
@@ -110,7 +104,6 @@ const layer = Layer.effect(
|
||||
const greptool = yield* GrepTool
|
||||
const patchtool = yield* ApplyPatchTool
|
||||
const skilltool = yield* SkillTool
|
||||
const codemode = yield* CodeModeTool
|
||||
const agent = yield* Agent.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
@@ -218,7 +211,6 @@ const layer = Layer.effect(
|
||||
question: Tool.init(question),
|
||||
lsp: Tool.init(lsptool),
|
||||
plan: Tool.init(plan),
|
||||
codemode: Tool.init(codemode),
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -240,7 +232,6 @@ const layer = Layer.effect(
|
||||
tool.patch,
|
||||
...(flags.experimentalLspTool ? [tool.lsp] : []),
|
||||
...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []),
|
||||
...(flags.experimentalCodeMode ? [tool.codemode] : []),
|
||||
],
|
||||
task: tool.task,
|
||||
read: tool.read,
|
||||
@@ -272,25 +263,11 @@ const layer = Layer.effect(
|
||||
return ["Available agent types and the tools they have access to:", description].join("\n")
|
||||
})
|
||||
|
||||
// The grouped MCP-tool catalog appended to the code-mode base description, built
|
||||
// fresh per turn so it tracks live tool-list changes. Hard-denied tools (the shared
|
||||
// Permission.visibleTools predicate over the agent's ruleset) never enter the
|
||||
// catalog, its inlined signatures, or the in-program search index.
|
||||
const describeCodeMode = Effect.fn("ToolRegistry.describeCodeMode")(function* (agent: Agent.Info, permission?: PermissionV1.Ruleset) {
|
||||
const visible = Permission.visibleTools(yield* mcp.tools(), Permission.merge(agent.permission, permission ?? []))
|
||||
const servers = Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize)
|
||||
return catalogInstructions(visible, yield* mcp.defs(), servers)
|
||||
})
|
||||
|
||||
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
|
||||
// Consulted once before the synchronous filter: code mode registers only when the
|
||||
// experimental flag is on AND at least one MCP tool is connected.
|
||||
const mcpToolCount = flags.experimentalCodeMode ? Object.keys(yield* mcp.tools()).length : 0
|
||||
const filtered = (yield* all()).filter((tool) => {
|
||||
if (tool.id === WebSearchTool.id) {
|
||||
return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel })
|
||||
}
|
||||
if (tool.id === CodeModeTool.id) return flags.experimentalCodeMode && mcpToolCount > 0
|
||||
|
||||
const usePatch =
|
||||
input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4")
|
||||
@@ -315,11 +292,7 @@ const layer = Layer.effect(
|
||||
: undefined
|
||||
return {
|
||||
id: tool.id,
|
||||
description: [
|
||||
output.description,
|
||||
tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,
|
||||
tool.id === CodeModeTool.id ? yield* describeCodeMode(input.agent, input.permission) : undefined,
|
||||
]
|
||||
description: [output.description, tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
parameters: output.parameters,
|
||||
@@ -341,7 +314,6 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
function isZodType(value: unknown): value is z.ZodType {
|
||||
return typeof value === "object" && value !== null && "_zod" in value
|
||||
}
|
||||
@@ -434,7 +406,6 @@ export const node = LayerNode.make({
|
||||
LSP.node,
|
||||
Instruction.node,
|
||||
FSUtil.node,
|
||||
MCP.node,
|
||||
EventV2Bridge.node,
|
||||
httpClient,
|
||||
CrossSpawnSpawner.node,
|
||||
|
||||
@@ -117,7 +117,6 @@ function makeMcp(instructions: MCP.ServerInstructions[] = []) {
|
||||
clients: () => Effect.succeed({}),
|
||||
instructions: () => Effect.succeed(instructions),
|
||||
tools: () => Effect.succeed({}),
|
||||
defs: () => Effect.succeed({}),
|
||||
prompts: () => Effect.succeed({}),
|
||||
resources: () => Effect.succeed({}),
|
||||
resourceTemplates: () => Effect.succeed({}),
|
||||
|
||||
@@ -39,7 +39,6 @@ const mcp = Layer.succeed(
|
||||
clients: () => Effect.succeed({}),
|
||||
instructions: () => Effect.succeed([]),
|
||||
tools: () => Effect.succeed({}),
|
||||
defs: () => Effect.succeed({}),
|
||||
prompts: () => Effect.succeed({}),
|
||||
resources: () => Effect.succeed({}),
|
||||
resourceTemplates: () => Effect.succeed({}),
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { SessionTools } from "@/session/tools"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Config } from "@/config/config"
|
||||
import { MCP } from "@/mcp"
|
||||
import { McpCatalog } from "@/mcp/catalog"
|
||||
import { Permission } from "@/permission"
|
||||
import { Plugin } from "@/plugin"
|
||||
import * as Truncate from "@/tool/truncate"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { disposeAllInstances } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { TestConfig } from "../fixture/config"
|
||||
import type { Tool as AITool } from "ai"
|
||||
|
||||
const configLayer = TestConfig.layer({
|
||||
directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
|
||||
})
|
||||
|
||||
// Fake MCP.Service built through the same `convertTool` path the real service uses,
|
||||
// so the raw registration loop dispatches through a genuine ai-sdk execute. The fake
|
||||
// client answers every call with a small text result.
|
||||
function fakeMcpLayer(tools: Record<string, { description: string }>) {
|
||||
const client = { callTool: async () => ({ content: [{ type: "text", text: "ok" }] }) }
|
||||
const converted: Record<string, AITool> = Object.fromEntries(
|
||||
Object.entries(tools).map(([key, def]) => [
|
||||
key,
|
||||
McpCatalog.convertTool(
|
||||
{
|
||||
name: key,
|
||||
description: def.description,
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
} as any,
|
||||
client as any,
|
||||
),
|
||||
]),
|
||||
)
|
||||
return Layer.mock(MCP.Service, {
|
||||
tools: () => Effect.succeed(converted),
|
||||
defs: () => Effect.succeed({}),
|
||||
clients: () => Effect.succeed({ github: { getServerCapabilities: () => undefined } } as any),
|
||||
})
|
||||
}
|
||||
|
||||
const mcpToolsLayer = fakeMcpLayer({
|
||||
github_create_issue: { description: "Create an issue" },
|
||||
github_list_issues: { description: "List issues" },
|
||||
})
|
||||
|
||||
const root = LayerNode.group([ToolRegistry.node, Agent.node, MCP.node, RuntimeFlags.node])
|
||||
const withCodeMode = testEffect(
|
||||
LayerNode.compile(root, [
|
||||
[Config.node, configLayer],
|
||||
[RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: true })],
|
||||
[MCP.node, mcpToolsLayer],
|
||||
]),
|
||||
)
|
||||
const withFlagOff = testEffect(
|
||||
LayerNode.compile(root, [
|
||||
[Config.node, configLayer],
|
||||
[RuntimeFlags.node, RuntimeFlags.layer()],
|
||||
[MCP.node, mcpToolsLayer],
|
||||
]),
|
||||
)
|
||||
|
||||
// Resolve the session tool record with the smallest honest stand-ins for the pieces
|
||||
// resolve reads: a fabricated model (only providerID/api.id are consulted by the
|
||||
// schema transform for these fixtures), a pass-through Truncate, an always-allow
|
||||
// Permission, and an optionally-observing Plugin.trigger. Registry, MCP, and flags
|
||||
// come from the compiled instance context.
|
||||
function resolveTools(trigger?: Plugin.Interface["trigger"]) {
|
||||
return Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const agent = yield* agents.defaultInfo()
|
||||
return yield* SessionTools.resolve({
|
||||
agent,
|
||||
model: { providerID: "opencode", api: { id: "test" } } as any,
|
||||
session: { id: SessionID.make("ses_session-tools"), permission: [] } as any,
|
||||
processor: {
|
||||
message: { id: MessageID.make("msg_session-tools") } as any,
|
||||
updateToolCall: () => Effect.succeed(undefined),
|
||||
completeToolCall: () => Effect.void,
|
||||
},
|
||||
bypassAgentCheck: false,
|
||||
messages: [],
|
||||
promptOps: {} as any,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Layer.mergeAll(
|
||||
Layer.mock(Permission.Service, { ask: () => Effect.void }),
|
||||
Layer.mock(Plugin.Service, {
|
||||
trigger:
|
||||
trigger ?? (((_name, _input, output) => Effect.succeed(output)) as Plugin.Interface["trigger"]),
|
||||
}),
|
||||
Layer.mock(Truncate.Service, {
|
||||
output: (text: string) => Effect.succeed({ content: text, truncated: false as const }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
describe("session.tools resolve", () => {
|
||||
withCodeMode.instance("code mode suppresses raw per-MCP registration behind the execute tool", () =>
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* resolveTools()
|
||||
const ids = Object.keys(tools)
|
||||
expect(ids).toContain("execute")
|
||||
expect(ids).not.toContain("github_create_issue")
|
||||
expect(ids).not.toContain("github_list_issues")
|
||||
}),
|
||||
)
|
||||
|
||||
withFlagOff.instance("without code mode the raw MCP tools register and execute is absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* resolveTools()
|
||||
const ids = Object.keys(tools)
|
||||
expect(ids).not.toContain("execute")
|
||||
expect(ids).toContain("github_create_issue")
|
||||
expect(ids).toContain("github_list_issues")
|
||||
}),
|
||||
)
|
||||
|
||||
withFlagOff.instance("legacy raw MCP execution fires plugin hooks keyed by the ai-sdk toolCallId", () =>
|
||||
Effect.gen(function* () {
|
||||
const events: { name: string; input: any; output: any }[] = []
|
||||
const trigger = ((name: unknown, input: unknown, output: unknown) =>
|
||||
Effect.sync(() => {
|
||||
events.push({ name: name as string, input, output })
|
||||
return output
|
||||
})) as Plugin.Interface["trigger"]
|
||||
const tools = yield* resolveTools(trigger)
|
||||
const raw = tools["github_create_issue"]!
|
||||
const result = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
raw.execute!(
|
||||
{ title: "x" },
|
||||
{ toolCallId: "call_legacy", abortSignal: new AbortController().signal, messages: [] },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect((result as any).output).toBe("ok")
|
||||
expect(events.map((e) => [e.name, e.input.tool, e.input.callID])).toEqual([
|
||||
["tool.execute.before", "github_create_issue", "call_legacy"],
|
||||
["tool.execute.after", "github_create_issue", "call_legacy"],
|
||||
])
|
||||
expect(events[0]!.output).toEqual({ args: { title: "x" } })
|
||||
// The after hook receives the raw MCP result, before model-facing shaping.
|
||||
expect(events[1]!.output).toMatchObject({ content: [{ type: "text", text: "ok" }] })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test"
|
||||
import { CodeModeTool, catalogInstructions } from "@/tool/code-mode"
|
||||
import { CodeModeTool, describeCatalog } from "@/tool/code-mode"
|
||||
import { McpCatalog } from "@/mcp/catalog"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { MCP } from "@/mcp"
|
||||
@@ -17,12 +17,9 @@ import {
|
||||
ListToolsRequestSchema,
|
||||
type Tool as MCPToolDef,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import type { Tool as AITool } from "ai"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
||||
// A 1x1 transparent PNG, base64-encoded, used to exercise image attachments.
|
||||
const PNG =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
||||
const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
||||
|
||||
const SERVER = "fixtures"
|
||||
|
||||
@@ -37,16 +34,7 @@ const ctx: Tool.Context = {
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal JSON-RPC MCP client speaking the real protocol over the in-memory transport.
|
||||
* Deliberately NOT the SDK `Client`: `test/mcp/lifecycle.test.ts` (and friends) replace
|
||||
* `@modelcontextprotocol/sdk/client/index.js` via bun's `mock.module`, which is
|
||||
* process-global and irreversible — in a full `bun test` run (CI) any later import of the
|
||||
* SDK Client gets the mock, whose `listTools` returns a canned `test_tool`. Speaking raw
|
||||
* JSON-RPC keeps this suite's "real server, real transport, real protocol" property while
|
||||
* being immune to that contamination. Only the surface `convertTool`/this file use is
|
||||
* implemented: connect handshake, tools/list, tools/call.
|
||||
*/
|
||||
// Avoid the SDK Client here; other MCP tests mock it process-globally.
|
||||
class RawJsonRpcClient {
|
||||
private nextId = 1
|
||||
private pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void }>()
|
||||
@@ -56,7 +44,7 @@ class RawJsonRpcClient {
|
||||
async connect() {
|
||||
this.transport.onmessage = (message) => {
|
||||
const msg = message as { id?: number; result?: unknown; error?: { message: string } }
|
||||
if (msg.id === undefined) return // notifications/requests from the server are not needed here
|
||||
if (msg.id === undefined) return
|
||||
const entry = this.pending.get(msg.id)
|
||||
if (!entry) return
|
||||
this.pending.delete(msg.id)
|
||||
@@ -83,16 +71,11 @@ class RawJsonRpcClient {
|
||||
return this.request("tools/list", {})
|
||||
}
|
||||
|
||||
/** The `convertTool` surface: schema/options (timeouts, progress) are SDK-client
|
||||
* concerns and are ignored here — the server never sees them. */
|
||||
callTool(params: { name: string; arguments?: Record<string, unknown> }, _schema?: unknown, _options?: unknown) {
|
||||
return this.request("tools/call", params)
|
||||
}
|
||||
}
|
||||
|
||||
// A real MCP server, exposed over an in-memory transport, with a representative mix
|
||||
// of tools: plain text, structured data (with an outputSchema), an image, and a
|
||||
// failing tool. Tools are defined with raw JSON Schema so outputSchema is exact.
|
||||
const TOOL_DEFS: MCPToolDef[] = [
|
||||
{
|
||||
name: "get_text",
|
||||
@@ -150,21 +133,15 @@ async function buildTool() {
|
||||
await client.connect()
|
||||
|
||||
const listed = (await client.listTools()).tools as MCPToolDef[]
|
||||
const mcpTools: Record<string, AITool> = {}
|
||||
const mcpDefs: Record<string, MCPToolDef> = {}
|
||||
const mcpTools: Record<string, MCP.McpTool> = {}
|
||||
for (const def of listed) {
|
||||
const key = McpCatalog.toolName(SERVER, def.name)
|
||||
mcpDefs[key] = def
|
||||
mcpTools[key] = McpCatalog.convertTool(def, client as unknown as Client)
|
||||
mcpTools[McpCatalog.toolName(SERVER, def.name)] = { def, client: client as unknown as Client }
|
||||
}
|
||||
|
||||
// Truncate echoes its input so assertions read the exact program output; Agent/Session
|
||||
// supply the (empty) permission rulesets the execute path merges; MCP serves the tools
|
||||
// this real in-memory server listed — the same snapshot shape the live service returns.
|
||||
const layer = Layer.mergeAll(
|
||||
Layer.mock(Plugin.Service, {
|
||||
trigger: (((_name: unknown, _input: unknown, output: unknown) =>
|
||||
Effect.succeed(output)) as Plugin.Interface["trigger"]),
|
||||
trigger: ((_name: unknown, _input: unknown, output: unknown) =>
|
||||
Effect.succeed(output)) as Plugin.Interface["trigger"],
|
||||
}),
|
||||
Layer.mock(Truncate.Service, {
|
||||
output: (text: string) => Effect.succeed({ content: text, truncated: false as const }),
|
||||
@@ -173,14 +150,12 @@ async function buildTool() {
|
||||
Layer.mock(Session.Service, { get: () => Effect.succeed({ permission: [] } as any) }),
|
||||
Layer.mock(MCP.Service, {
|
||||
tools: () => Effect.succeed(mcpTools),
|
||||
defs: () => Effect.succeed(mcpDefs),
|
||||
clients: () => Effect.succeed({ [SERVER]: {} as any }),
|
||||
}),
|
||||
)
|
||||
return {
|
||||
tool: await Effect.runPromise(CodeModeTool.pipe(Effect.flatMap(Tool.init), Effect.provide(layer))),
|
||||
// The catalog section the registry appends to the base description (describeCodeMode).
|
||||
description: catalogInstructions(mcpTools, mcpDefs, [SERVER]),
|
||||
description: describeCatalog(mcpTools, [SERVER]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,14 +171,10 @@ describe("code mode integration (real MCP server)", () => {
|
||||
test("the appended catalog inlines full signatures with real MCP schemas", () => {
|
||||
expect(description).toContain("Available tools (COMPLETE list")
|
||||
expect(description).toContain("- fixtures (4 tools)")
|
||||
expect(description).toContain(
|
||||
"tools.fixtures.add(input: { a: number; b: number }): Promise<{ sum: number }>",
|
||||
)
|
||||
expect(description).toContain("tools.fixtures.add(input: { a: number; b: number }): Promise<{ sum: number }>")
|
||||
expect(description).toContain("tools.fixtures.get_text(input: { name: string }): Promise<unknown>")
|
||||
expect(description).toContain("// Add two numbers and return the structured sum")
|
||||
// Small catalog: everything is inline, so no discovery tool is advertised.
|
||||
expect(description).not.toContain("$codemode")
|
||||
// The workflow section is present with placeholder-only call forms.
|
||||
expect(description).toContain("## Workflow")
|
||||
expect(description).toContain("`const res = await tools.<namespace>.<tool>(input)`")
|
||||
expect(description).not.toContain("total_count")
|
||||
@@ -252,7 +223,6 @@ describe("code mode integration (real MCP server)", () => {
|
||||
value: "[1 image attached to the result]",
|
||||
})
|
||||
expect(out.output).not.toContain(PNG)
|
||||
// The stripped image still arrives as a real attachment.
|
||||
expect(out.attachments).toHaveLength(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
CODE_MODE_TOOL,
|
||||
CodeModeTool,
|
||||
Parameters,
|
||||
catalogInstructions,
|
||||
formatValue,
|
||||
groupByServer,
|
||||
toSandboxResult,
|
||||
withLogs,
|
||||
type Attachment,
|
||||
} from "@/tool/code-mode"
|
||||
import { CODE_MODE_TOOL, CodeModeTool, Parameters, describeCatalog } from "@/tool/code-mode"
|
||||
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
|
||||
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||
import { Agent } from "@/agent/agent"
|
||||
@@ -19,9 +9,7 @@ import { Plugin } from "@/plugin"
|
||||
import { Session } from "@/session/session"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import * as Truncate from "@/tool/truncate"
|
||||
import { McpCatalog } from "@/mcp/catalog"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import type { Tool as AITool } from "ai"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
|
||||
const ctx: Tool.Context = {
|
||||
@@ -35,28 +23,22 @@ const ctx: Tool.Context = {
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
// Build a real MCP-derived AI SDK tool over a fake transport, so the adapter exercises
|
||||
// the same `convertTool` execution path that `mcp.tools()` produces at runtime.
|
||||
function mcpTool(
|
||||
name: string,
|
||||
handler: (args: Record<string, unknown>) => unknown,
|
||||
inputSchema: Record<string, unknown> = { type: "object", properties: {} },
|
||||
): AITool {
|
||||
const client = {
|
||||
callTool: async (params: { arguments?: Record<string, unknown> }) => handler(params.arguments ?? {}),
|
||||
outputSchema?: Record<string, unknown>,
|
||||
): MCP.McpTool {
|
||||
return {
|
||||
def: { name, description: name, inputSchema, ...(outputSchema ? { outputSchema } : {}) } as MCPToolDef,
|
||||
client: {
|
||||
callTool: async (params: { arguments?: Record<string, unknown> }) => handler(params.arguments ?? {}),
|
||||
} as unknown as MCP.McpTool["client"],
|
||||
}
|
||||
return McpCatalog.convertTool({ name, description: name, inputSchema } as any, client as any)
|
||||
}
|
||||
|
||||
// Truncate echoes its input so assertions read the exact program output. Agent.get is
|
||||
// consulted by the shared wrapper during truncation AND at execute time for the
|
||||
// permission ruleset that filters the dispatchable tool tree; Session.get supplies the
|
||||
// (empty) session-level ruleset half of that merge. Plugin.trigger defaults to the
|
||||
// pass-through the real service uses when no plugin implements a hook; tests observing
|
||||
// or failing hooks override it.
|
||||
function harness(input: {
|
||||
mcpTools: Record<string, AITool>
|
||||
defs?: Record<string, MCPToolDef>
|
||||
mcpTools: Record<string, MCP.McpTool>
|
||||
servers: string[]
|
||||
permission?: PermissionV1.Rule[]
|
||||
trigger?: Plugin.Interface["trigger"]
|
||||
@@ -76,21 +58,17 @@ function harness(input: {
|
||||
}),
|
||||
Layer.mock(MCP.Service, {
|
||||
tools: () => Effect.succeed(input.mcpTools),
|
||||
defs: () => Effect.succeed(input.defs ?? {}),
|
||||
clients: () => Effect.succeed(Object.fromEntries(input.servers.map((name) => [name, {} as any]))),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Derive sanitized server namespaces from the catalog keys, mirroring how the registry
|
||||
// passes `Object.keys(mcp.clients()).map(sanitize)`.
|
||||
function serverNames(mcpTools: Record<string, AITool>, servers?: string[]) {
|
||||
function serverNames(mcpTools: Record<string, MCP.McpTool>, servers?: string[]) {
|
||||
return servers ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))]
|
||||
}
|
||||
|
||||
function build(
|
||||
mcpTools: Record<string, AITool>,
|
||||
defs: Record<string, MCPToolDef> = {},
|
||||
mcpTools: Record<string, MCP.McpTool>,
|
||||
servers?: string[],
|
||||
permission?: PermissionV1.Rule[],
|
||||
trigger?: Plugin.Interface["trigger"],
|
||||
@@ -99,20 +77,13 @@ function build(
|
||||
return Effect.runPromise(
|
||||
CodeModeTool.pipe(
|
||||
Effect.flatMap(Tool.init),
|
||||
Effect.provide(harness({ mcpTools, defs, servers: names, permission, trigger })),
|
||||
Effect.provide(harness({ mcpTools, servers: names, permission, trigger })),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// The agent-facing description, as the registry composes it (`describeCodeMode`):
|
||||
// permission-filtered tool set → grouped catalog → CodeMode instructions.
|
||||
function describeFor(
|
||||
mcpTools: Record<string, AITool>,
|
||||
defs: Record<string, MCPToolDef> = {},
|
||||
servers?: string[],
|
||||
permission: PermissionV1.Rule[] = [],
|
||||
) {
|
||||
return catalogInstructions(Permission.visibleTools(mcpTools, permission), defs, serverNames(mcpTools, servers))
|
||||
function describeFor(mcpTools: Record<string, MCP.McpTool>, servers?: string[], permission: PermissionV1.Rule[] = []) {
|
||||
return describeCatalog(Permission.visibleTools(mcpTools, permission), serverNames(mcpTools, servers))
|
||||
}
|
||||
|
||||
describe("code mode execute", () => {
|
||||
@@ -123,38 +94,30 @@ describe("code mode execute", () => {
|
||||
})
|
||||
|
||||
test("groups multi-underscore server names by longest matching prefix", () => {
|
||||
const groups = groupByServer({ my_server_do_thing: mcpTool("do_thing", () => "") }, ["my_server"])
|
||||
expect([...groups.keys()]).toEqual(["my_server"])
|
||||
expect(groups.get("my_server")![0]).toMatchObject({
|
||||
path: "my_server.do_thing",
|
||||
local: "do_thing",
|
||||
key: "my_server_do_thing",
|
||||
})
|
||||
const description = describeFor({ my_server_do_thing: mcpTool("do_thing", () => "") }, ["my_server"])
|
||||
expect(description).toContain("- my_server (1 tool)")
|
||||
expect(description).toContain("tools.my_server.do_thing(")
|
||||
})
|
||||
|
||||
test("groupByServer uses the whole key as the server name when it has no underscore", () => {
|
||||
const groups = groupByServer({ standalone: mcpTool("standalone", () => "") }, [])
|
||||
expect([...groups.keys()]).toEqual(["standalone"])
|
||||
expect(groups.get("standalone")![0]).toMatchObject({
|
||||
path: "standalone.standalone",
|
||||
server: "standalone",
|
||||
local: "standalone",
|
||||
key: "standalone",
|
||||
})
|
||||
const description = describeFor({ standalone: mcpTool("standalone", () => "") }, [])
|
||||
expect(description).toContain("- standalone (1 tool)")
|
||||
expect(description).toContain("tools.standalone.standalone(")
|
||||
})
|
||||
|
||||
test("groupByServer carries the raw MCP schemas for rendering", () => {
|
||||
const defs: Record<string, MCPToolDef> = {
|
||||
weather_current: {
|
||||
name: "current",
|
||||
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||
outputSchema: { type: "object", properties: { tempC: { type: "number" } }, required: ["tempC"] },
|
||||
} as any,
|
||||
}
|
||||
const groups = groupByServer({ weather_current: mcpTool("current", () => "") }, ["weather"], defs)
|
||||
const entry = groups.get("weather")![0]!
|
||||
expect(entry.inputSchema).toEqual(defs.weather_current!.inputSchema as any)
|
||||
expect(entry.outputSchema).toEqual(defs.weather_current!.outputSchema as any)
|
||||
test("describeCatalog carries the raw MCP schemas for rendering", () => {
|
||||
const description = describeFor(
|
||||
{
|
||||
weather_current: mcpTool(
|
||||
"current",
|
||||
() => "",
|
||||
{ type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||
{ type: "object", properties: { tempC: { type: "number" } }, required: ["tempC"] },
|
||||
),
|
||||
},
|
||||
["weather"],
|
||||
)
|
||||
expect(description).toContain("tools.weather.current(input: { city: string }): Promise<{ tempC: number }>")
|
||||
})
|
||||
|
||||
test("the static base description carries no catalog; the registry appends it", async () => {
|
||||
@@ -184,90 +147,76 @@ describe("code mode execute", () => {
|
||||
)
|
||||
expect(description).toContain("tools.github.list_issues(")
|
||||
expect(description).toContain("tools.linear.search(")
|
||||
// A schema with no properties renders as an empty object, not `{ }`.
|
||||
expect(description).toContain("tools.linear.search(input: {}): Promise<unknown>")
|
||||
// Fully inlined catalog: no discovery round-trip is needed or advertised.
|
||||
expect(description).not.toContain("$codemode")
|
||||
expect(description).not.toContain("Browse one namespace")
|
||||
// The workflow/rules sections use placeholder call forms only — the example machinery
|
||||
// never cherry-picks a catalog tool or fabricates result fields.
|
||||
expect(description).toContain("## Workflow")
|
||||
expect(description).toContain("1. Pick a tool from the list under `## Available tools`")
|
||||
expect(description).toContain('`const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string')
|
||||
expect(description).toContain(
|
||||
'`const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string',
|
||||
)
|
||||
expect(description).toContain("Return only the fields you need")
|
||||
expect(description).not.toContain("total_count")
|
||||
})
|
||||
|
||||
test("signatures render the declared outputSchema as the return type", () => {
|
||||
const defs: Record<string, MCPToolDef> = {
|
||||
weather_current: {
|
||||
name: "current",
|
||||
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||
outputSchema: {
|
||||
const description = describeFor({
|
||||
weather_current: mcpTool(
|
||||
"current",
|
||||
() => "",
|
||||
{ type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||
{
|
||||
type: "object",
|
||||
properties: { tempC: { type: "number" }, summary: { type: "string" } },
|
||||
required: ["tempC"],
|
||||
},
|
||||
} as any,
|
||||
}
|
||||
const description = describeFor({ weather_current: mcpTool("current", () => "") }, defs)
|
||||
),
|
||||
})
|
||||
expect(description).toContain(
|
||||
"tools.weather.current(input: { city: string }): Promise<{ tempC: number; summary?: string }>",
|
||||
)
|
||||
})
|
||||
|
||||
test("large catalogs inline a budgeted PARTIAL list plus runtime search", async () => {
|
||||
const tools: Record<string, AITool> = {}
|
||||
const tools: Record<string, MCP.McpTool> = {}
|
||||
const filler = "a searchable description of this operation that consumes catalog budget ".repeat(3)
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const client = { callTool: async () => ({ content: [] }) }
|
||||
tools[`alpha_op_${i}`] = McpCatalog.convertTool(
|
||||
{
|
||||
tools[`alpha_op_${i}`] = {
|
||||
def: {
|
||||
name: `op_${i}`,
|
||||
description: `${filler}${i}`,
|
||||
inputSchema: { type: "object", properties: { value: { type: "string" }, count: { type: "number" } } },
|
||||
} as any,
|
||||
client as any,
|
||||
)
|
||||
} as MCPToolDef,
|
||||
client: { callTool: async () => ({ content: [] }) } as unknown as MCP.McpTool["client"],
|
||||
}
|
||||
}
|
||||
tools["zeta_only_tool"] = mcpTool("only_tool", () => "", {
|
||||
type: "object",
|
||||
properties: { topic: { type: "string", description: "Subject to look up" } },
|
||||
required: ["topic"],
|
||||
})
|
||||
const description = describeFor(tools, {}, ["alpha", "zeta"])
|
||||
const description = describeFor(tools, ["alpha", "zeta"])
|
||||
|
||||
// Every namespace is listed with counts; signatures inline round-robin across
|
||||
// namespaces (cheapest-first within each) until the budget runs out, and the
|
||||
// description states exactly how comprehensive the list is. Round-robin fairness:
|
||||
// the small zeta namespace is fully shown even though alpha alone could exhaust
|
||||
// the whole budget.
|
||||
expect(description).toContain("Available tools (PARTIAL - ")
|
||||
expect(description).toMatch(/- alpha \(150 tools, \d+ shown\)/)
|
||||
expect(description).toContain("- zeta (1 tool)\n")
|
||||
expect(description).toContain("tools.zeta.only_tool(input: { topic: string }): Promise<unknown>")
|
||||
expect(description).toContain("tools.$codemode.search(")
|
||||
// PARTIAL catalogs put search first in the workflow and advertise namespace browsing.
|
||||
expect(description).toContain("1. Find a tool (skip when it is already listed below)")
|
||||
expect(description).toContain('- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.')
|
||||
expect(description).toContain(
|
||||
'- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.',
|
||||
)
|
||||
expect(description).not.toContain("total_count")
|
||||
// All op lines cost the same estimated tokens (chars/4 rounds away the 1- vs 3-digit
|
||||
// name difference), so the path tiebreak decides: the lexicographically-first ops made
|
||||
// the cut and the lexicographic tail (op_99 is maximal) did not.
|
||||
expect(description).toContain("tools.alpha.op_0(")
|
||||
expect(description).not.toContain("tools.alpha.op_99(")
|
||||
|
||||
// The runtime search tool works in-program and returns complete signatures.
|
||||
const tool = await build(tools, {}, ["alpha", "zeta"])
|
||||
const tool = await build(tools, ["alpha", "zeta"])
|
||||
const out = await Effect.runPromise(
|
||||
tool.execute({ code: "return await tools.$codemode.search({ query: 'only tool', limit: 3 })" }, ctx),
|
||||
)
|
||||
const result = JSON.parse(out.output)
|
||||
// Search-result paths carry the `tools.` prefix so each is directly usable as a call site.
|
||||
expect(result.items.map((i: any) => i.path)).toContain("tools.zeta.only_tool")
|
||||
expect(result.items[0].signature).toContain("tools.")
|
||||
// Search results render the pretty multiline signature: MCP input-property
|
||||
// descriptions ride along as JSDoc field comments. The inline catalog stays compact.
|
||||
const signature = result.items.find((i: any) => i.path === "tools.zeta.only_tool").signature
|
||||
expect(signature).toContain("tools.zeta.only_tool(input: {\n")
|
||||
expect(signature).toContain(" /** Subject to look up */\n topic: string")
|
||||
@@ -290,7 +239,10 @@ describe("code mode execute", () => {
|
||||
linear_search: mcpTool("search", () => ""),
|
||||
})
|
||||
const output = await Effect.runPromise(
|
||||
tool.execute({ code: "const namespaces = Object.keys(tools); return { namespaces, count: namespaces.length }" }, ctx),
|
||||
tool.execute(
|
||||
{ code: "const namespaces = Object.keys(tools); return { namespaces, count: namespaces.length }" },
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
expect(JSON.parse(output.output)).toEqual({ namespaces: ["github", "linear"], count: 2 })
|
||||
})
|
||||
@@ -414,7 +366,6 @@ describe("code mode execute", () => {
|
||||
|
||||
expect(output.output).toBe("denied: permission denied by user")
|
||||
expect(output.metadata.error).toBeUndefined()
|
||||
// The MCP tool itself never ran.
|
||||
expect(called).toEqual([])
|
||||
expect(output.metadata.toolCalls).toEqual([{ tool: "a.tool", status: "error" }])
|
||||
})
|
||||
@@ -431,7 +382,6 @@ describe("code mode execute", () => {
|
||||
a_tool: mcpTool("a", () => ({ content: [{ type: "text", text: "one" }] })),
|
||||
b_tool: mcpTool("b", () => ({ content: [{ type: "text", text: "two" }] })),
|
||||
},
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
trigger,
|
||||
@@ -442,7 +392,6 @@ describe("code mode execute", () => {
|
||||
)
|
||||
|
||||
expect(out.output).toBe("done")
|
||||
// callID is synthetic and per-execution: `${parentCallID}/${n}`, n starting at 1.
|
||||
expect(events.map((e) => [e.name, e.input.tool, e.input.callID])).toEqual([
|
||||
["tool.execute.before", "a_tool", "call_code_mode/1"],
|
||||
["tool.execute.after", "a_tool", "call_code_mode/1"],
|
||||
@@ -453,7 +402,6 @@ describe("code mode execute", () => {
|
||||
expect(before!.input.sessionID).toBe(ctx.sessionID)
|
||||
expect(before!.output).toEqual({ args: { x: 1 } })
|
||||
expect(after!.input.args).toEqual({ x: 1 })
|
||||
// The after hook sees the raw MCP result — the same payload the legacy path passes.
|
||||
expect(after!.output).toEqual({ content: [{ type: "text", text: "one" }] })
|
||||
})
|
||||
|
||||
@@ -469,7 +417,6 @@ describe("code mode execute", () => {
|
||||
}
|
||||
const tool = await build(
|
||||
{ a_tool: mcpTool("a", record("a")), b_tool: mcpTool("b", record("b")) },
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
trigger,
|
||||
@@ -489,10 +436,8 @@ describe("code mode execute", () => {
|
||||
),
|
||||
)
|
||||
|
||||
// The program handled the hook failure; the rest ran and the outer result is ok.
|
||||
expect(out.metadata.error).toBeUndefined()
|
||||
expect(out.output).toBe("hook exploded / ok")
|
||||
// The before hook gates dispatch: the failed child's tool never executed.
|
||||
expect(called).toEqual(["b"])
|
||||
})
|
||||
|
||||
@@ -508,7 +453,6 @@ describe("code mode execute", () => {
|
||||
tool.execute({ code: "await tools.greeter.hello({ name: 'Ada' }); return 'done'" }, recordingCtx),
|
||||
)
|
||||
|
||||
// The UI sees the call appear as running, then resolve to completed.
|
||||
expect(snapshots).toContainEqual({
|
||||
toolCalls: [{ tool: "greeter.hello", status: "running", input: { name: "Ada" } }],
|
||||
})
|
||||
@@ -546,7 +490,6 @@ describe("code mode execute", () => {
|
||||
})
|
||||
|
||||
const out = await Effect.runPromise(tool.execute({ code: "return await tools.shot.take({})" }, ctx))
|
||||
// The program received the structured content; the media rode along host-side.
|
||||
expect(JSON.parse(out.output)).toEqual({ name: "shot.png" })
|
||||
expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,PNGDATA" }])
|
||||
expect(out.output).not.toContain("PNGDATA")
|
||||
@@ -561,13 +504,72 @@ describe("code mode execute", () => {
|
||||
expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,PNGDATA" }])
|
||||
})
|
||||
|
||||
test("media-only markers distinguish all-image from mixed attachments", async () => {
|
||||
const tool = await build({
|
||||
media_images: mcpTool("images", () => ({
|
||||
content: [
|
||||
{ type: "image", data: "PNG1", mimeType: "image/png" },
|
||||
{ type: "image", data: "PNG2", mimeType: "image/png" },
|
||||
],
|
||||
})),
|
||||
media_mixed: mcpTool("mixed", () => ({
|
||||
content: [
|
||||
{ type: "image", data: "PNG3", mimeType: "image/png" },
|
||||
{ type: "resource", resource: { uri: "file:///tmp/report.pdf", mimeType: "application/pdf", blob: "PDF1" } },
|
||||
],
|
||||
})),
|
||||
})
|
||||
const out = await Effect.runPromise(
|
||||
tool.execute(
|
||||
{
|
||||
code: `
|
||||
const images = await tools.media.images({})
|
||||
const mixed = await tools.media.mixed({})
|
||||
return { images, mixed }
|
||||
`,
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
expect(JSON.parse(out.output)).toEqual({
|
||||
images: "[2 images attached to the result]",
|
||||
mixed: "[2 files attached to the result]",
|
||||
})
|
||||
expect(out.output).not.toContain("PNG")
|
||||
expect(out.attachments).toEqual([
|
||||
{ type: "file", mime: "image/png", url: "data:image/png;base64,PNG1" },
|
||||
{ type: "file", mime: "image/png", url: "data:image/png;base64,PNG2" },
|
||||
{ type: "file", mime: "image/png", url: "data:image/png;base64,PNG3" },
|
||||
{ type: "file", mime: "application/pdf", url: "data:application/pdf;base64,PDF1", filename: "report.pdf" },
|
||||
])
|
||||
})
|
||||
|
||||
test("resource links flow to the program as text, never as attachments", async () => {
|
||||
const tool = await build({
|
||||
docs_find: mcpTool("find", () => ({
|
||||
content: [
|
||||
{
|
||||
type: "resource_link",
|
||||
uri: "https://example.com/guide.pdf",
|
||||
name: "guide.pdf",
|
||||
mimeType: "application/pdf",
|
||||
},
|
||||
{ type: "resource_link", uri: "file:///tmp/notes.md", name: "notes.md" },
|
||||
],
|
||||
})),
|
||||
})
|
||||
const out = await Effect.runPromise(tool.execute({ code: "return await tools.docs.find({})" }, ctx))
|
||||
|
||||
expect(out.output).toBe("guide.pdf: https://example.com/guide.pdf\nnotes.md: file:///tmp/notes.md")
|
||||
expect(out.attachments).toBeUndefined()
|
||||
})
|
||||
|
||||
test("attachments still flow when the program returns something else entirely", async () => {
|
||||
const tool = await build({
|
||||
shot_take: mcpTool("take", () => ({ content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }] })),
|
||||
})
|
||||
const out = await Effect.runPromise(
|
||||
tool.execute({ code: "await tools.shot.take({}); return 'captured'" }, ctx),
|
||||
)
|
||||
const out = await Effect.runPromise(tool.execute({ code: "await tools.shot.take({}); return 'captured'" }, ctx))
|
||||
expect(out.output).toBe("captured")
|
||||
expect(out.attachments).toHaveLength(1)
|
||||
})
|
||||
@@ -579,9 +581,6 @@ describe("code mode execute", () => {
|
||||
})
|
||||
|
||||
test("cancelling via ctx.abort interrupts the running program", async () => {
|
||||
// The child call triggers the abort itself, deterministically, while the program
|
||||
// swallows the call's failure and heads into an infinite loop. If abort did not
|
||||
// interrupt the execution fiber, this test would hang on the busy loop.
|
||||
const controller = new AbortController()
|
||||
const tool = await build({
|
||||
host_trigger: mcpTool("trigger", () => {
|
||||
@@ -613,8 +612,6 @@ describe("code mode execute", () => {
|
||||
})
|
||||
|
||||
test("leaves oversized results to OpenCode's native tool-output truncation", async () => {
|
||||
// No CodeMode output limit is set, so the full result reaches the shared Tool.define
|
||||
// wrapper intact (the harness Truncate fake passes it through un-truncated).
|
||||
const tool = await build({})
|
||||
const output = await Effect.runPromise(tool.execute({ code: "return 'x'.repeat(40000)" }, ctx))
|
||||
expect(output.metadata.error).toBeUndefined()
|
||||
@@ -649,7 +646,7 @@ describe("code mode permission visibility", () => {
|
||||
github_create_issue: mcpTool("create_issue", ok),
|
||||
github_list_issues: mcpTool("list_issues", ok),
|
||||
}
|
||||
const description = describeFor(mcpTools, {}, ["github"], [deny("github_create_issue")])
|
||||
const description = describeFor(mcpTools, ["github"], [deny("github_create_issue")])
|
||||
expect(description).toContain("tools.github.list_issues(")
|
||||
expect(description).not.toContain("create_issue")
|
||||
expect(description).toContain("- github (1 tool)")
|
||||
@@ -660,7 +657,7 @@ describe("code mode permission visibility", () => {
|
||||
github_create_issue: mcpTool("create_issue", ok),
|
||||
github_list_issues: mcpTool("list_issues", ok),
|
||||
}
|
||||
const description = describeFor(mcpTools, {}, ["github"], [askRule("github_create_issue")])
|
||||
const description = describeFor(mcpTools, ["github"], [askRule("github_create_issue")])
|
||||
expect(description).toContain("tools.github.create_issue(")
|
||||
expect(description).toContain("tools.github.list_issues(")
|
||||
expect(description).toContain("- github (2 tools)")
|
||||
@@ -676,7 +673,6 @@ describe("code mode permission visibility", () => {
|
||||
}),
|
||||
github_list_issues: mcpTool("list_issues", ok),
|
||||
},
|
||||
{},
|
||||
["github"],
|
||||
[deny("github_create_issue")],
|
||||
)
|
||||
@@ -689,10 +685,7 @@ describe("code mode permission visibility", () => {
|
||||
expect(denied.output).not.toContain("permission")
|
||||
expect(called).toEqual([])
|
||||
|
||||
// The rest of the namespace still works.
|
||||
const allowed = await Effect.runPromise(
|
||||
tool.execute({ code: "return await tools.github.list_issues({})" }, ctx),
|
||||
)
|
||||
const allowed = await Effect.runPromise(tool.execute({ code: "return await tools.github.list_issues({})" }, ctx))
|
||||
expect(allowed.metadata.error).toBeUndefined()
|
||||
expect(allowed.output).toBe("ok")
|
||||
})
|
||||
@@ -702,13 +695,10 @@ describe("code mode permission visibility", () => {
|
||||
const askCtx: Tool.Context = { ...ctx, ask: (req) => Effect.sync(() => void asked.push(req.permission)) }
|
||||
const tool = await build(
|
||||
{ github_list_issues: mcpTool("list_issues", ok) },
|
||||
{},
|
||||
["github"],
|
||||
[askRule("github_list_issues")],
|
||||
)
|
||||
const out = await Effect.runPromise(
|
||||
tool.execute({ code: "return await tools.github.list_issues({})" }, askCtx),
|
||||
)
|
||||
const out = await Effect.runPromise(tool.execute({ code: "return await tools.github.list_issues({})" }, askCtx))
|
||||
expect(out.output).toBe("ok")
|
||||
expect(asked).toEqual(["github_list_issues"])
|
||||
})
|
||||
@@ -718,144 +708,8 @@ describe("code mode permission visibility", () => {
|
||||
const visible = Permission.visibleTools(tools, [
|
||||
deny("a_tool"),
|
||||
askRule("b_tool"),
|
||||
// A scoped (non-"*") deny does not hide the tool from the catalog.
|
||||
{ permission: "c_tool", pattern: "something", action: "deny" },
|
||||
])
|
||||
expect(Object.keys(visible)).toEqual(["b_tool", "c_tool"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("toSandboxResult", () => {
|
||||
const collector = () => {
|
||||
const attachments: Attachment[] = []
|
||||
return { attachments, collect: (a: Attachment) => void attachments.push(a) }
|
||||
}
|
||||
|
||||
test("prefers structuredContent over text", () => {
|
||||
const { collect } = collector()
|
||||
expect(toSandboxResult({ structuredContent: { x: 1 }, content: [{ type: "text", text: "hi" }] }, collect)).toEqual(
|
||||
{ x: 1 },
|
||||
)
|
||||
})
|
||||
|
||||
test("joins text content when no structured content is present", () => {
|
||||
const { collect } = collector()
|
||||
expect(
|
||||
toSandboxResult(
|
||||
{ content: [{ type: "text", text: "one" }, { type: "text", text: "two" }] },
|
||||
collect,
|
||||
),
|
||||
).toBe("one\ntwo")
|
||||
})
|
||||
|
||||
test("passes non-MCP values through untouched", () => {
|
||||
const { collect } = collector()
|
||||
expect(toSandboxResult("raw", collect)).toBe("raw")
|
||||
expect(toSandboxResult(42, collect)).toBe(42)
|
||||
expect(toSandboxResult(null, collect)).toBeNull()
|
||||
expect(toSandboxResult({ some: "object" }, collect)).toEqual({ some: "object" })
|
||||
})
|
||||
|
||||
test("strips media into the accumulator; text stays the sandbox value", () => {
|
||||
const { attachments, collect } = collector()
|
||||
const value = toSandboxResult(
|
||||
{
|
||||
content: [
|
||||
{ type: "text", text: "see image" },
|
||||
{ type: "image", data: "AAAA", mimeType: "image/png" },
|
||||
],
|
||||
},
|
||||
collect,
|
||||
)
|
||||
expect(value).toBe("see image")
|
||||
expect(attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,AAAA" }])
|
||||
})
|
||||
|
||||
test("a media-only result yields a marker; counts and nouns follow the content", () => {
|
||||
const one = collector()
|
||||
expect(toSandboxResult({ content: [{ type: "image", data: "A", mimeType: "image/png" }] }, one.collect)).toBe(
|
||||
"[1 image attached to the result]",
|
||||
)
|
||||
|
||||
const two = collector()
|
||||
expect(
|
||||
toSandboxResult(
|
||||
{
|
||||
content: [
|
||||
{ type: "image", data: "A", mimeType: "image/png" },
|
||||
{ type: "image", data: "B", mimeType: "image/jpeg" },
|
||||
],
|
||||
},
|
||||
two.collect,
|
||||
),
|
||||
).toBe("[2 images attached to the result]")
|
||||
expect(two.attachments).toHaveLength(2)
|
||||
|
||||
const mixed = collector()
|
||||
expect(
|
||||
toSandboxResult(
|
||||
{
|
||||
content: [
|
||||
{ type: "image", data: "A", mimeType: "image/png" },
|
||||
{ type: "audio", data: "B", mimeType: "audio/wav" },
|
||||
],
|
||||
},
|
||||
mixed.collect,
|
||||
),
|
||||
).toBe("[2 files attached to the result]")
|
||||
})
|
||||
|
||||
test("extracts embedded resources: text inline, blobs as attachments with filenames", () => {
|
||||
const { attachments, collect } = collector()
|
||||
const value = toSandboxResult(
|
||||
{
|
||||
content: [
|
||||
{ type: "resource", resource: { uri: "file:///tmp/notes.txt", mimeType: "text/plain", text: "note text" } },
|
||||
{ type: "resource", resource: { uri: "file:///tmp/doc.pdf", mimeType: "application/pdf", blob: "PDF" } },
|
||||
],
|
||||
},
|
||||
collect,
|
||||
)
|
||||
expect(value).toBe("note text")
|
||||
expect(attachments).toEqual([
|
||||
{ type: "file", mime: "application/pdf", url: "data:application/pdf;base64,PDF", filename: "doc.pdf" },
|
||||
])
|
||||
})
|
||||
|
||||
test("collects resource_link blocks as external-URL attachments", () => {
|
||||
const { attachments, collect } = collector()
|
||||
const value = toSandboxResult(
|
||||
{ content: [{ type: "resource_link", uri: "https://example.com/report.csv", mimeType: "text/csv" }] },
|
||||
collect,
|
||||
)
|
||||
expect(value).toBe("[1 file attached to the result]")
|
||||
expect(attachments).toEqual([
|
||||
{ type: "file", mime: "text/csv", url: "https://example.com/report.csv", filename: "report.csv" },
|
||||
])
|
||||
})
|
||||
|
||||
test("an MCP-shaped result with nothing extractable becomes null", () => {
|
||||
const { collect } = collector()
|
||||
expect(toSandboxResult({ content: [] }, collect)).toBeNull()
|
||||
expect(toSandboxResult({ content: [{ type: "mystery" }] }, collect)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatting helpers", () => {
|
||||
test("formatValue", () => {
|
||||
expect(formatValue("text")).toBe("text")
|
||||
expect(formatValue({ a: 1 })).toBe(JSON.stringify({ a: 1 }, null, 2))
|
||||
expect(formatValue(null)).toBe("null")
|
||||
expect(formatValue(undefined)).toBe("undefined")
|
||||
})
|
||||
|
||||
test("withLogs", () => {
|
||||
// No logs: output is returned untouched.
|
||||
expect(withLogs("result", [])).toBe("result")
|
||||
expect(withLogs("result")).toBe("result")
|
||||
// Logs are appended as a trailing section, one line each.
|
||||
expect(withLogs("result", ["a", "[warn] b"])).toBe("result\n\nLogs:\na\n[warn] b")
|
||||
// Empty output still gets the section (no leading blank lines).
|
||||
expect(withLogs("", ["[error] boom"])).toBe("Logs:\n[error] boom")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,8 +17,6 @@ import { InstanceState } from "@/effect/instance-state"
|
||||
import { ToolJsonSchema } from "@/tool/json-schema"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { MCP } from "@/mcp"
|
||||
import { McpCatalog } from "@/mcp/catalog"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
@@ -59,45 +57,6 @@ const replacements = [
|
||||
const it = testEffect(LayerNode.compile(root, replacements))
|
||||
const withBrokenPlugin = testEffect(LayerNode.compile(root, [...replacements, [Plugin.node, brokenPluginLayer]]))
|
||||
|
||||
// Fake MCP.Service serving two tools on one server, built through the same
|
||||
// `convertTool` path the real service uses so the code-mode catalog renders
|
||||
// genuine signatures.
|
||||
function fakeMcpLayer(tools: Record<string, { description: string }>) {
|
||||
const client = { callTool: async () => ({ content: [] }) }
|
||||
const converted = Object.fromEntries(
|
||||
Object.entries(tools).map(([key, def]) => [
|
||||
key,
|
||||
McpCatalog.convertTool(
|
||||
{
|
||||
name: key,
|
||||
description: def.description,
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
} as any,
|
||||
client as any,
|
||||
),
|
||||
]),
|
||||
)
|
||||
return Layer.mock(MCP.Service, {
|
||||
tools: () => Effect.succeed(converted),
|
||||
defs: () => Effect.succeed({}),
|
||||
clients: () => Effect.succeed(Object.keys(tools).length > 0 ? ({ github: {} } as any) : {}),
|
||||
})
|
||||
}
|
||||
|
||||
const mcpToolsLayer = fakeMcpLayer({
|
||||
github_create_issue: { description: "Create an issue" },
|
||||
github_list_issues: { description: "List issues" },
|
||||
})
|
||||
|
||||
const codeModeFlags = [RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: true })] as const
|
||||
const withCodeMode = testEffect(
|
||||
LayerNode.compile(root, [[Config.node, configLayer], codeModeFlags, [MCP.node, mcpToolsLayer]]),
|
||||
)
|
||||
const withCodeModeNoMcp = testEffect(
|
||||
LayerNode.compile(root, [[Config.node, configLayer], codeModeFlags, [MCP.node, fakeMcpLayer({})]]),
|
||||
)
|
||||
const withFlagOff = testEffect(LayerNode.compile(root, [...replacements, [MCP.node, mcpToolsLayer]]))
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
@@ -532,82 +491,3 @@ describe("tool.registry", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("tool.registry code mode", () => {
|
||||
const model = { providerID: ProviderV2.ID.opencode, modelID: ModelV2.ID.make("test") }
|
||||
|
||||
withCodeMode.instance("registers the code-mode execute tool when the flag is on and MCP tools exist", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const agents = yield* Agent.Service
|
||||
const tools = yield* registry.tools({ ...model, agent: yield* agents.defaultInfo() })
|
||||
const execute = tools.find((tool) => tool.id === "execute")
|
||||
if (!execute) throw new Error("code-mode execute tool was not registered")
|
||||
// The registry appends the grouped catalog to the static base description.
|
||||
expect(execute.description).toContain("confined runtime")
|
||||
expect(execute.description).toContain("## Available tools")
|
||||
expect(execute.description).toContain("tools.github.create_issue(")
|
||||
expect(execute.description).toContain("tools.github.list_issues(")
|
||||
}),
|
||||
)
|
||||
|
||||
withCodeModeNoMcp.instance("does not register code mode when no MCP tools are connected", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const agents = yield* Agent.Service
|
||||
const tools = yield* registry.tools({ ...model, agent: yield* agents.defaultInfo() })
|
||||
expect(tools.map((tool) => tool.id)).not.toContain("execute")
|
||||
}),
|
||||
)
|
||||
|
||||
withFlagOff.instance("does not register code mode when the flag is off, even with MCP tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const agents = yield* Agent.Service
|
||||
const tools = yield* registry.tools({ ...model, agent: yield* agents.defaultInfo() })
|
||||
expect(tools.map((tool) => tool.id)).not.toContain("execute")
|
||||
}),
|
||||
)
|
||||
|
||||
withCodeMode.instance("hard-denied MCP tools are dropped from the catalog; ask-level ones stay", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const agents = yield* Agent.Service
|
||||
const base = yield* agents.defaultInfo()
|
||||
const agent = {
|
||||
...base,
|
||||
permission: [
|
||||
...base.permission,
|
||||
{ permission: "github_create_issue", pattern: "*" as const, action: "deny" as const },
|
||||
{ permission: "github_list_issues", pattern: "*" as const, action: "ask" as const },
|
||||
],
|
||||
}
|
||||
const tools = yield* registry.tools({ ...model, agent })
|
||||
const execute = tools.find((tool) => tool.id === "execute")
|
||||
if (!execute) throw new Error("code-mode execute tool was not registered")
|
||||
expect(execute.description).not.toContain("create_issue")
|
||||
expect(execute.description).toContain("tools.github.list_issues(")
|
||||
expect(execute.description).toContain("- github (1 tool)")
|
||||
}),
|
||||
)
|
||||
|
||||
withCodeMode.instance("session-level hard-denied MCP tools are dropped from the code-mode catalog", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const agents = yield* Agent.Service
|
||||
const tools = yield* registry.tools({
|
||||
...model,
|
||||
agent: yield* agents.defaultInfo(),
|
||||
permission: [
|
||||
{ permission: "github_create_issue", pattern: "*" as const, action: "deny" as const },
|
||||
{ permission: "github_list_issues", pattern: "*" as const, action: "ask" as const },
|
||||
],
|
||||
})
|
||||
const execute = tools.find((tool) => tool.id === "execute")
|
||||
if (!execute) throw new Error("code-mode execute tool was not registered")
|
||||
expect(execute.description).not.toContain("create_issue")
|
||||
expect(execute.description).toContain("tools.github.list_issues(")
|
||||
expect(execute.description).toContain("- github (1 tool)")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -230,6 +230,7 @@
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
text-transform: capitalize;
|
||||
color: var(--task-agent-legacy-color, var(--text-strong));
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-subtitle"] {
|
||||
@@ -247,6 +248,57 @@
|
||||
}
|
||||
}
|
||||
|
||||
body[data-new-layout] {
|
||||
[data-component="task-tool-card"] {
|
||||
padding: 8px 12px 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 0;
|
||||
border-color: transparent;
|
||||
background: var(--task-agent-background, light-dark(#fafafa, rgba(250, 250, 250, 0.15)));
|
||||
box-shadow: inset 0 0 0 0.5px var(--task-agent-border, var(--v2-border-border-base));
|
||||
|
||||
[data-component="task-tool-spinner"],
|
||||
[data-component="task-tool-title"] {
|
||||
color: var(--task-agent-color, var(--text-strong));
|
||||
}
|
||||
|
||||
[data-component="task-tool-title"] {
|
||||
height: 20px;
|
||||
font-family: var(--v2-font-family-sans, "Inter", sans-serif);
|
||||
font-style: normal;
|
||||
font-weight: 530;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.04px;
|
||||
flex: none;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-subtitle"] {
|
||||
font-family: var(--v2-font-family-sans, "Inter", sans-serif);
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.04px;
|
||||
color: light-dark(var(--v2-text-text-base), #fafafa);
|
||||
font-variation-settings: "slnt" 0;
|
||||
flex: none;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] {
|
||||
color: var(--task-agent-color, light-dark(var(--v2-text-text-base), #ffffff));
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: transparent;
|
||||
background: var(--task-agent-background, light-dark(#fafafa, rgba(250, 250, 250, 0.15)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body:not([data-new-layout]) {
|
||||
[data-component="tool-trigger"] {
|
||||
[data-slot="basic-tool-tool-spinner"] {
|
||||
|
||||
@@ -62,6 +62,7 @@ import { animate } from "motion"
|
||||
import { useLocation } from "@solidjs/router"
|
||||
import { attached, inline, kind } from "./message-file"
|
||||
import { readPartText } from "./message-part-text"
|
||||
import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
|
||||
|
||||
async function writeClipboard(text: string): Promise<boolean> {
|
||||
const body = typeof document === "undefined" ? undefined : document.body
|
||||
@@ -370,6 +371,44 @@ const agentTones: Record<string, string> = {
|
||||
plan: "var(--icon-agent-plan-base)",
|
||||
}
|
||||
|
||||
const v2AgentTones: Record<string, { color: string; border: string; background: string }> = {
|
||||
build: {
|
||||
color: "var(--v2-agent-build-solid)",
|
||||
border: "var(--v2-agent-build-border)",
|
||||
background: "var(--v2-agent-build-background)",
|
||||
},
|
||||
explore: {
|
||||
color: "var(--v2-agent-explore-solid)",
|
||||
border: "var(--v2-agent-explore-border)",
|
||||
background: "var(--v2-agent-explore-background)",
|
||||
},
|
||||
plan: {
|
||||
color: "var(--v2-agent-plan-solid)",
|
||||
border: "var(--v2-agent-plan-border)",
|
||||
background: "var(--v2-agent-plan-background)",
|
||||
},
|
||||
}
|
||||
|
||||
const agentThemeColors: Record<string, string> = {
|
||||
primary: "var(--text-interactive-base)",
|
||||
secondary: "var(--text-base)",
|
||||
accent: "var(--icon-info-base)",
|
||||
success: "var(--icon-success-base)",
|
||||
warning: "var(--icon-warning-base)",
|
||||
error: "var(--icon-critical-base)",
|
||||
info: "var(--icon-info-base)",
|
||||
}
|
||||
|
||||
const v2AgentThemeColors: Record<string, string> = {
|
||||
primary: "var(--v2-text-text-accent)",
|
||||
secondary: "var(--v2-text-text-muted)",
|
||||
accent: "var(--v2-icon-icon-accent)",
|
||||
success: "var(--v2-state-fg-success)",
|
||||
warning: "var(--v2-state-fg-warning)",
|
||||
error: "var(--v2-state-fg-danger)",
|
||||
info: "var(--v2-state-fg-info)",
|
||||
}
|
||||
|
||||
const agentPalette = [
|
||||
"var(--icon-agent-ask-base)",
|
||||
"var(--icon-agent-build-base)",
|
||||
@@ -394,16 +433,31 @@ function tone(name: string) {
|
||||
function taskAgent(
|
||||
raw: unknown,
|
||||
list?: readonly { name: string; color?: string }[],
|
||||
): { name?: string; color?: string } {
|
||||
): { name?: string; color?: string; v2Color?: string; border?: string; background?: string } {
|
||||
if (typeof raw !== "string" || !raw) return {}
|
||||
const key = raw.toLowerCase()
|
||||
const item = list?.find((entry) => entry.name === raw || entry.name.toLowerCase() === key)
|
||||
const v2Tone = item?.color ? undefined : v2AgentTones[key]
|
||||
const color = agentColor(item?.color, agentThemeColors) ?? agentTones[key] ?? tone(key)
|
||||
const v2Color = agentColor(item?.color, v2AgentThemeColors) ?? v2Tone?.color ?? color
|
||||
return {
|
||||
name: item?.name ?? `${raw[0]!.toUpperCase()}${raw.slice(1)}`,
|
||||
color: item?.color ?? agentTones[key] ?? tone(key),
|
||||
color,
|
||||
v2Color,
|
||||
border: v2Tone?.border ?? `color-mix(in srgb, ${v2Color} 48%, transparent)`,
|
||||
background: v2Tone?.background ?? `color-mix(in srgb, ${v2Color} 12%, transparent)`,
|
||||
}
|
||||
}
|
||||
|
||||
function agentColor(value: string | undefined, themeColors: Record<string, string>) {
|
||||
if (!value) return
|
||||
return themeColors[value] ?? value
|
||||
}
|
||||
|
||||
function newLayout() {
|
||||
return typeof document !== "undefined" && document.body.hasAttribute("data-new-layout")
|
||||
}
|
||||
|
||||
function webSearchProviderLabel(provider: unknown) {
|
||||
if (provider === "parallel") return "Parallel Web Search"
|
||||
if (provider === "exa") return "Exa Web Search"
|
||||
@@ -1882,6 +1936,9 @@ ToolRegistry.register({
|
||||
const agent = createMemo(() => taskAgent(props.input.subagent_type, data.store.agent))
|
||||
const title = createMemo(() => agent().name ?? i18n.t("ui.tool.agent.default"))
|
||||
const tone = createMemo(() => agent().color)
|
||||
const v2Tone = createMemo(() => agent().v2Color)
|
||||
const border = createMemo(() => agent().border)
|
||||
const background = createMemo(() => agent().background)
|
||||
const subtitle = createMemo(() => {
|
||||
const value =
|
||||
typeof props.input.description === "string" && props.input.description
|
||||
@@ -1921,17 +1978,27 @@ ToolRegistry.register({
|
||||
}
|
||||
|
||||
const trigger = () => (
|
||||
<div data-component="task-tool-card">
|
||||
<div
|
||||
data-component="task-tool-card"
|
||||
style={{
|
||||
"--task-agent-color": v2Tone(),
|
||||
"--task-agent-legacy-color": tone(),
|
||||
"--task-agent-border": border(),
|
||||
"--task-agent-background": background(),
|
||||
}}
|
||||
>
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<Show when={running()}>
|
||||
<span data-component="task-tool-spinner" style={{ color: tone() ?? "var(--icon-interactive-base)" }}>
|
||||
<Spinner />
|
||||
<Show when={newLayout()} fallback={<Spinner />}>
|
||||
<SessionProgressIndicatorV2
|
||||
style={{ color: v2Tone() ?? "light-dark(var(--v2-text-text-base), #ffffff)" }}
|
||||
/>
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
<span data-component="task-tool-title" style={{ color: tone() ?? "var(--text-strong)" }}>
|
||||
{title()}
|
||||
</span>
|
||||
<span data-component="task-tool-title">{title()}</span>
|
||||
<Show when={subtitle()}>
|
||||
<span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>
|
||||
</Show>
|
||||
|
||||
@@ -281,6 +281,15 @@
|
||||
--icon-agent-docs-base: #fcb239;
|
||||
--icon-agent-ask-base: #2090f5;
|
||||
--icon-agent-build-base: #034cff;
|
||||
--v2-agent-plan-solid: var(--v2-pink-900);
|
||||
--v2-agent-plan-border: rgba(200, 61, 139, 0.5);
|
||||
--v2-agent-plan-background: rgba(253, 236, 243, 0.33);
|
||||
--v2-agent-build-solid: var(--v2-blue-900);
|
||||
--v2-agent-build-border: rgba(59, 92, 246, 0.5);
|
||||
--v2-agent-build-background: rgba(236, 241, 254, 0.33);
|
||||
--v2-agent-explore-solid: var(--v2-yellow-900);
|
||||
--v2-agent-explore-border: rgba(142, 114, 49, 0.5);
|
||||
--v2-agent-explore-background: rgba(254, 250, 236, 0.33);
|
||||
--icon-on-success-base: rgba(18, 201, 5, 0.9);
|
||||
--icon-on-success-hover: rgba(45, 186, 38, 0.9);
|
||||
--icon-on-success-selected: rgba(7, 137, 1, 0.9);
|
||||
@@ -541,6 +550,15 @@
|
||||
--icon-agent-docs-base: #fbb73c;
|
||||
--icon-agent-ask-base: #2090f5;
|
||||
--icon-agent-build-base: #9dbefe;
|
||||
--v2-agent-plan-solid: var(--v2-pink-400);
|
||||
--v2-agent-plan-border: rgba(250, 188, 216, 0.5);
|
||||
--v2-agent-plan-background: rgba(247, 213, 228, 0.1);
|
||||
--v2-agent-build-solid: var(--v2-blue-400);
|
||||
--v2-agent-build-border: rgba(215, 226, 252, 0.5);
|
||||
--v2-agent-build-background: rgba(215, 226, 252, 0.1);
|
||||
--v2-agent-explore-solid: var(--v2-yellow-400);
|
||||
--v2-agent-explore-border: rgba(247, 229, 181, 0.5);
|
||||
--v2-agent-explore-background: rgba(252, 239, 208, 0.1);
|
||||
--icon-on-success-base: rgba(18, 201, 5, 0.9);
|
||||
--icon-on-success-hover: rgba(53, 192, 45, 0.9);
|
||||
--icon-on-success-selected: rgba(77, 225, 68, 0.9);
|
||||
|
||||
@@ -3,6 +3,30 @@ import { V2_AVATAR_DARK, V2_AVATAR_LIGHT } from "./avatar"
|
||||
|
||||
const ref = (name: string): V2ColorValue => `var(--${name})`
|
||||
|
||||
const lightAgentTokens: Record<string, V2ColorValue> = {
|
||||
"v2-agent-plan-solid": ref("v2-pink-900"),
|
||||
"v2-agent-plan-border": "rgba(200, 61, 139, 0.5)",
|
||||
"v2-agent-plan-background": "rgba(253, 236, 243, 0.33)",
|
||||
"v2-agent-build-solid": ref("v2-blue-900"),
|
||||
"v2-agent-build-border": "rgba(59, 92, 246, 0.5)",
|
||||
"v2-agent-build-background": "rgba(236, 241, 254, 0.33)",
|
||||
"v2-agent-explore-solid": ref("v2-yellow-900"),
|
||||
"v2-agent-explore-border": "rgba(142, 114, 49, 0.5)",
|
||||
"v2-agent-explore-background": "rgba(254, 250, 236, 0.33)",
|
||||
}
|
||||
|
||||
const darkAgentTokens: Record<string, V2ColorValue> = {
|
||||
"v2-agent-plan-solid": ref("v2-pink-400"),
|
||||
"v2-agent-plan-border": "rgba(250, 188, 216, 0.5)",
|
||||
"v2-agent-plan-background": "rgba(247, 213, 228, 0.1)",
|
||||
"v2-agent-build-solid": ref("v2-blue-400"),
|
||||
"v2-agent-build-border": "rgba(215, 226, 252, 0.5)",
|
||||
"v2-agent-build-background": "rgba(215, 226, 252, 0.1)",
|
||||
"v2-agent-explore-solid": ref("v2-yellow-400"),
|
||||
"v2-agent-explore-border": "rgba(247, 229, 181, 0.5)",
|
||||
"v2-agent-explore-background": "rgba(252, 239, 208, 0.1)",
|
||||
}
|
||||
|
||||
const light: Record<string, V2ColorValue> = {
|
||||
"v2-background-bg-base": ref("v2-grey-100"),
|
||||
"v2-background-bg-deep": ref("v2-grey-200"),
|
||||
@@ -46,6 +70,7 @@ const light: Record<string, V2ColorValue> = {
|
||||
"v2-state-bg-info": ref("v2-blue-100"),
|
||||
"v2-state-fg-info": ref("v2-blue-800"),
|
||||
"v2-state-border-info": ref("v2-blue-300"),
|
||||
...lightAgentTokens,
|
||||
...V2_AVATAR_LIGHT,
|
||||
"v2-elevation-raised":
|
||||
"0px 2px 4px 0px var(--v2-alpha-dark-4), 0px 1px 2px -1px var(--v2-alpha-dark-8), 0px 0px 0px 0.5px var(--v2-alpha-dark-12), 0px 0px 0px 0px var(--v2-alpha-dark-0)",
|
||||
@@ -110,6 +135,7 @@ const dark: Record<string, V2ColorValue> = {
|
||||
"v2-state-bg-info": ref("v2-blue-1200"),
|
||||
"v2-state-fg-info": ref("v2-blue-500"),
|
||||
"v2-state-border-info": ref("v2-blue-900"),
|
||||
...darkAgentTokens,
|
||||
...V2_AVATAR_DARK,
|
||||
"v2-elevation-raised":
|
||||
"0px 2px 4px 0px var(--v2-alpha-dark-30), 0px 1px 2px 0px var(--v2-alpha-dark-30), 0px 0px 0px 0.5px var(--v2-alpha-light-16), 0px -0.5px 0px 0px var(--v2-alpha-light-6)",
|
||||
|
||||
@@ -81,6 +81,13 @@
|
||||
--project-avatar-border: var(--v2-avatar-border-gray);
|
||||
}
|
||||
|
||||
[data-slot="project-avatar-surface"][data-variant="outline"] {
|
||||
--project-avatar-bg: var(--v2-background-bg-base);
|
||||
--project-avatar-border: var(--v2-border-border-strong);
|
||||
color: var(--v2-text-text-muted);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
[data-slot="project-avatar-surface"][data-has-image] {
|
||||
background: var(--project-avatar-bg);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ Saturated 16px project avatar with color variants and optional unread dot.
|
||||
|
||||
### Variants
|
||||
- Color: orange, yellow, cyan, green, red, pink, blue, purple, gray.
|
||||
- Outline: neutral, muted style for de-emphasized projects (e.g. recently closed).
|
||||
- Image vs initial content state.
|
||||
- Unread dot with corner mask when \`unread\` is set.
|
||||
|
||||
@@ -33,7 +34,7 @@ export default {
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: "select",
|
||||
options: [...PROJECT_AVATAR_VARIANTS],
|
||||
options: [...PROJECT_AVATAR_VARIANTS, "outline"],
|
||||
},
|
||||
},
|
||||
args: {
|
||||
@@ -62,6 +63,13 @@ export const AllVariants = {
|
||||
),
|
||||
}
|
||||
|
||||
export const Outline = {
|
||||
args: {
|
||||
fallback: "O",
|
||||
variant: "outline",
|
||||
},
|
||||
}
|
||||
|
||||
export const Unread = {
|
||||
args: {
|
||||
fallback: "O",
|
||||
|
||||
@@ -26,10 +26,13 @@ export const PROJECT_AVATAR_VARIANTS = [
|
||||
|
||||
export type ProjectAvatarVariant = (typeof PROJECT_AVATAR_VARIANTS)[number]
|
||||
|
||||
// "outline" is a neutral, muted style (e.g. recently closed projects) and is not part of the color rotation.
|
||||
export type ProjectAvatarStyle = ProjectAvatarVariant | "outline"
|
||||
|
||||
export interface ProjectAvatarProps extends ComponentProps<"div"> {
|
||||
fallback: string
|
||||
src?: string
|
||||
variant?: ProjectAvatarVariant
|
||||
variant?: ProjectAvatarStyle
|
||||
unread?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,16 @@
|
||||
--v2-state-fg-info: var(--v2-blue-800);
|
||||
--v2-state-border-info: var(--v2-blue-300);
|
||||
|
||||
--v2-agent-plan-solid: var(--v2-pink-900);
|
||||
--v2-agent-plan-border: rgba(200, 61, 139, 0.5);
|
||||
--v2-agent-plan-background: rgba(253, 236, 243, 0.33);
|
||||
--v2-agent-build-solid: var(--v2-blue-900);
|
||||
--v2-agent-build-border: rgba(59, 92, 246, 0.5);
|
||||
--v2-agent-build-background: rgba(236, 241, 254, 0.33);
|
||||
--v2-agent-explore-solid: var(--v2-yellow-900);
|
||||
--v2-agent-explore-border: rgba(142, 114, 49, 0.5);
|
||||
--v2-agent-explore-background: rgba(254, 250, 236, 0.33);
|
||||
|
||||
/* ── Project avatar (fixed; theme-independent) ── */
|
||||
--v2-avatar-fg: #ffffffff;
|
||||
--v2-avatar-bg-orange: #ee7330ff;
|
||||
@@ -193,6 +203,16 @@
|
||||
--v2-state-fg-info: var(--v2-blue-500);
|
||||
--v2-state-border-info: var(--v2-blue-900);
|
||||
|
||||
--v2-agent-plan-solid: var(--v2-pink-400);
|
||||
--v2-agent-plan-border: rgba(250, 188, 216, 0.5);
|
||||
--v2-agent-plan-background: rgba(247, 213, 228, 0.1);
|
||||
--v2-agent-build-solid: var(--v2-blue-400);
|
||||
--v2-agent-build-border: rgba(215, 226, 252, 0.5);
|
||||
--v2-agent-build-background: rgba(215, 226, 252, 0.1);
|
||||
--v2-agent-explore-solid: var(--v2-yellow-400);
|
||||
--v2-agent-explore-border: rgba(247, 229, 181, 0.5);
|
||||
--v2-agent-explore-background: rgba(252, 239, 208, 0.1);
|
||||
|
||||
--v2-elevation-raised:
|
||||
0px 2px 4px 0px var(--v2-alpha-dark-30),
|
||||
0px 1px 2px 0px var(--v2-alpha-dark-30),
|
||||
@@ -296,6 +316,16 @@
|
||||
--v2-state-fg-info: var(--v2-blue-800);
|
||||
--v2-state-border-info: var(--v2-blue-300);
|
||||
|
||||
--v2-agent-plan-solid: var(--v2-pink-900);
|
||||
--v2-agent-plan-border: rgba(200, 61, 139, 0.5);
|
||||
--v2-agent-plan-background: rgba(253, 236, 243, 0.33);
|
||||
--v2-agent-build-solid: var(--v2-blue-900);
|
||||
--v2-agent-build-border: rgba(59, 92, 246, 0.5);
|
||||
--v2-agent-build-background: rgba(236, 241, 254, 0.33);
|
||||
--v2-agent-explore-solid: var(--v2-yellow-900);
|
||||
--v2-agent-explore-border: rgba(142, 114, 49, 0.5);
|
||||
--v2-agent-explore-background: rgba(254, 250, 236, 0.33);
|
||||
|
||||
--v2-elevation-raised:
|
||||
0px 2px 4px 0px var(--v2-alpha-dark-4), 0px 1px 2px -1px var(--v2-alpha-dark-8),
|
||||
0px 0px 0px 0.5px var(--v2-alpha-dark-12), 0px 0px 0px 0px var(--v2-alpha-dark-0);
|
||||
@@ -406,6 +436,16 @@
|
||||
--v2-state-fg-info: var(--v2-blue-500);
|
||||
--v2-state-border-info: var(--v2-blue-900);
|
||||
|
||||
--v2-agent-plan-solid: var(--v2-pink-400);
|
||||
--v2-agent-plan-border: rgba(250, 188, 216, 0.5);
|
||||
--v2-agent-plan-background: rgba(247, 213, 228, 0.1);
|
||||
--v2-agent-build-solid: var(--v2-blue-400);
|
||||
--v2-agent-build-border: rgba(215, 226, 252, 0.5);
|
||||
--v2-agent-build-background: rgba(215, 226, 252, 0.1);
|
||||
--v2-agent-explore-solid: var(--v2-yellow-400);
|
||||
--v2-agent-explore-border: rgba(247, 229, 181, 0.5);
|
||||
--v2-agent-explore-background: rgba(252, 239, 208, 0.1);
|
||||
|
||||
--v2-avatar-fg: #ffffffff;
|
||||
--v2-avatar-bg-orange: #723d22ff;
|
||||
--v2-avatar-border-orange: #ff8648ff;
|
||||
|
||||
Reference in New Issue
Block a user