feat: experimental codemode (#34677)

This commit is contained in:
Aiden Cline 2026-07-02 23:48:34 -05:00 committed by GitHub
parent 04d236ceed
commit cb93114424
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 11329 additions and 45 deletions

View file

@ -19,6 +19,7 @@
"@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",
@ -139,6 +140,20 @@
"effect",
],
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "0.0.1",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
"typescript": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.17.13",
@ -579,6 +594,7 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@ -1924,6 +1940,8 @@
"@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"],
"@opencode-ai/codemode": ["@opencode-ai/codemode@workspace:packages/codemode"],
"@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"],
"@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"],
@ -3010,7 +3028,7 @@
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@ -3582,7 +3600,7 @@
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
@ -4504,7 +4522,7 @@
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"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": ["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-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=="],
@ -5354,7 +5372,7 @@
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
"undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="],
"undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
@ -5556,7 +5574,7 @@
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
@ -5604,8 +5622,6 @@
"@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=="],
@ -5692,6 +5708,8 @@
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="],
"@astrojs/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"@astrojs/sitemap/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
@ -5856,6 +5874,8 @@
"@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=="],
@ -5866,12 +5886,16 @@
"@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=="],
@ -5902,6 +5926,8 @@
"@malept/flatpak-bundler/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=="],
"@mdx-js/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
@ -5920,6 +5946,8 @@
"@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=="],
@ -6152,6 +6180,8 @@
"astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
"astro/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
"astro/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="],
@ -6192,6 +6222,8 @@
"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=="],
@ -6242,6 +6274,8 @@
"engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
"esast-util-from-js/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
@ -6306,6 +6340,8 @@
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
"micromark-extension-mdxjs/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
@ -6326,9 +6362,7 @@
"nitro/undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="],
"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/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@ -6432,7 +6466,7 @@
"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=="],
@ -6452,6 +6486,8 @@
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
@ -6466,6 +6502,8 @@
"verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
"vite-plugin-dynamic-import/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"vitest/@vitest/expect": ["@vitest/expect@4.1.7", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w=="],
@ -6884,6 +6922,8 @@
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@storybook/csf-plugin/unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
@ -6912,8 +6952,6 @@
"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=="],
@ -7044,6 +7082,10 @@
"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=="],
@ -7056,6 +7098,8 @@
"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=="],
@ -7324,6 +7368,8 @@
"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=="],
@ -7346,8 +7392,6 @@
"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=="],
@ -7456,6 +7500,8 @@
"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=="],

View file

@ -99,6 +99,7 @@
"@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",

View file

@ -0,0 +1,15 @@
# @opencode-ai/codemode
- This local package owns confined execution over explicit schema-described tools. Applications own authorization, persistence, external authority, and tool-specific delivery semantics.
- Do not add a speculative generic permission or approval policy. A host omits tools it does not expose and enforces domain authorization inside each provided tool.
- Keep Code Mode unaware of host session, channel, and conversation models. The hosting application supplies trusted execution scope around it.
- Tool schemas are the model-facing Interface. Keep arguments minimal and natural to the operation; never add unrelated IDs as ambient capability tokens.
## Future Design Notes
- If a captured user-visible output channel returns (an earlier `output.text`/`output.file`/`output.image` API was removed from v1), keep `output` as its name, distinct from the program return value: `return` stays the structured result for the model, while `output.*` describes artifacts the host may render into a conversation or UI after execution. Keep this host-neutral and let applications decide how captured output is delivered. In v1, hosts collect media host-side (outside the sandbox) instead.
- Improve the sandbox failure taxonomy. Distinguish parse/compile mistakes, unsupported syntax, user-thrown errors, invalid returned data, tool refusal, tool internal failure, timeout, and genuine runtime defects so agents can recover accurately instead of treating everything as a generic execution failure.
- Preserve the public/private error split. Tool authors should be able to return a safe model-visible message while retaining a private cause for host diagnostics. Unknown host failures must remain sanitized by default.
- Think deliberately about richer binary boundaries before allowing `Blob`, `File`, `ArrayBuffer`, streams, or typed arrays beyond todays JSON-like values. If CodeMode supports binary tool args/results, use explicit tagged data shapes and clear size limits rather than relying on ambient runtime serialization.
- Keep host capabilities explicit. Globals such as `fetch`, `crypto`, filesystem handles, extra modules, or network clients should be opt-in runtime capabilities with obvious policy defaults, not ambient authority. Default to unavailable unless a host deliberately provides the capability.
- If `fetch` is added, model it as a host-provided outbound capability with policy controls: allowed origins, methods, headers, response size, timeout, and whether response bodies may be returned, emitted, or only summarized through a tool.

337
packages/codemode/README.md Normal file
View file

@ -0,0 +1,337 @@
# @opencode-ai/codemode
Effect-native confined code execution over explicit, schema-described tools.
CodeMode lets a model write a small JavaScript program that can call only the tools supplied by the host. The program can sequence calls, transform plain data, branch, loop, and run independent calls in parallel without receiving ambient filesystem, process, network, module, or application authority.
The package is currently private to this workspace. Its API is designed around three uses:
```ts
// One execution
yield* CodeMode.execute({ tools, code })
// A reusable runtime
const runtime = CodeMode.make({ tools, limits })
yield* runtime.execute(code)
// One agent-facing code tool
const codeTool = runtime.agentTool()
```
## Install
Within this workspace:
```json
{
"dependencies": {
"@opencode-ai/codemode": "workspace:*"
}
}
```
Hosts interact with CodeMode through `effect` (tool `run` implementations, `Effect`-typed results), so they should depend on `effect` themselves.
## Quick Start
Define tools with Effect Schema, then place them in the object tree exposed to programs as `tools`:
```ts
import { CodeMode, Tool } from "@opencode-ai/codemode"
import { Effect, Schema } from "effect"
const lookupOrder = Tool.make({
description: "Look up an order by ID",
input: Schema.Struct({ id: Schema.String }),
output: Schema.Struct({ id: Schema.String, status: Schema.String }),
run: ({ id }) => Effect.succeed({ id, status: "open" }),
})
const runtime = CodeMode.make({
tools: {
orders: {
lookup: lookupOrder,
},
},
})
const result = yield* runtime.execute(`
const order = await tools.orders.lookup({ id: "order_42" })
return { id: order.id, needsAttention: order.status !== "complete" }
`)
```
`result` is always an `ExecuteResult`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption.
Successful result values are JSON-safe data. A program that returns `undefined`, including by reaching the end without `return`, produces `null`; nested `undefined` values are normalized to `null` as well.
## API
### `Tool.make`
```ts
const tool = Tool.make({
description,
input, // Effect Schema (validating) or JSON Schema (render-only)
output, // optional; same choice
run,
})
```
`input` and `output` each accept a validating Effect Schema or a render-only JSON Schema document (the natural shape for adapter-provided tools whose schemas arrive as JSON Schema, e.g. MCP definitions). Effect Schema input is decoded before `run` is invoked, and `run` returns the encoded representation of an Effect Schema `output`, which CodeMode decodes and copies before exposing it to the program. JSON Schemas only shape the model-visible signature; values pass through unvalidated (they still cross the plain-data boundary).
`output` is optional. Without it the tool's signature advertises `Promise<unknown>` and the host result is exposed as-is.
The description and schemas are part of the model-visible tool contract. Keep descriptions concrete and put authorization in `run` or in the service it calls.
### `CodeMode.execute`
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),
})
```
The Effect environment is inferred from the supplied tools. CodeMode does not erase service requirements introduced by tool implementations.
### `CodeMode.make`
Use `CodeMode.make` when the tool set and execution policy are reused:
```ts
const runtime = CodeMode.make({
tools: { orders: { lookup: lookupOrder } },
limits: { timeoutMs: 30_000 },
})
runtime.catalog() // structured tool descriptions
runtime.instructions() // model-facing syntax and tool guide
runtime.execute(source) // ExecuteResult
runtime.agentTool() // { name, description, input, output, execute }
```
`catalog`, `instructions`, and `agentTool` are projections of the same configured tool tree. `agentTool().description` is exactly `instructions()`.
### Results
```ts
type ExecuteResult = ExecuteSuccess | ExecuteFailure
interface ExecuteSuccess {
readonly ok: true
readonly value: Schema.Json
readonly logs?: ReadonlyArray<string>
readonly truncated?: boolean
readonly toolCalls: ReadonlyArray<ToolCall>
}
interface ExecuteFailure {
readonly ok: false
readonly error: Diagnostic
readonly logs?: ReadonlyArray<string>
readonly truncated?: boolean
readonly toolCalls: ReadonlyArray<ToolCall>
}
```
`toolCalls` contains the names of calls admitted by the runtime in call order. It is retained on failure so hosts can audit partial execution without exposing inputs or host failures. `truncated` is present when the value or logs were cut to fit `maxOutputBytes` (see Execution Limits).
### Tool-call hooks
`onToolCallStart` receives `{ index, name, input }` after input decoding and before tool execution. The input is decoded host-side data and may include values produced by schema transformations; applications should avoid logging sensitive tool arguments indiscriminately.
`onToolCallEnd` receives `{ index, name, input, durationMs, outcome, message? }` when an admitted call settles. `outcome` is `"success"` or `"failure"`; `message` is the model-safe failure message and is present only on failure. Interrupted calls (for example when the execution timeout fires) do not produce an end event. Both hooks are Effect-returning and must not fail.
## Discovery
The agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count regardless of budget, and as many complete tool signatures (each with a one-line description) as fit an estimated-token budget are inlined. Selection is round-robin across namespaces for fairness: in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to place its next-cheapest signature line against the shared budget, and a namespace whose next line does not fit drops out while the others keep going — so every namespace gets some representation before any namespace gets everything. The instructions state exactly how comprehensive the list is, both overall (`COMPLETE list` vs `PARTIAL — N of M shown`) and per namespace (`(3 tools)`, `(3 tools, 1 shown)`, `(3 tools, none shown)`).
The default budget is 2,000 estimated tokens (characters / 4, the same heuristic OpenCode uses). Override it when constructing a runtime:
```ts
const runtime = CodeMode.make({
tools,
discovery: { maxInlineCatalogTokens: 6_000 },
})
```
The budget must be a non-negative safe integer.
The runtime search tool is always registered — including when the catalog is fully inlined — so a speculative `tools.$codemode.search` call never fails as an unknown tool. It is only advertised in the instructions when the inlined list is partial:
```ts
const matches = await tools.$codemode.search({
query: "order status",
namespace: "orders", // optional: scope to one top-level namespace
limit: 10,
})
```
`search` performs deterministic, additive field-weighted matching. The query is tokenized (camelCase boundaries split; every non-alphanumeric character is a separator; empties and `*` are dropped), and each term scores every tool: exact path or path-segment match (20), path substring (8), description substring (4), and searchable-text substring (2). Each term also carries naive singular variants (trailing `s`/`es` stripped), and a field check passes when the term or any variant matches — so a plural query term (`issues`) still finds a tool whose text only says `issue`, without changing the weights. The searchable text also includes the input schema's property names and their description strings, so a query naming a parameter finds its tool, and substring matching means partial words match. Scores sum across terms; matches are sorted by score (ties broken alphabetically by path) and capped at `limit` results (default 10).
Each result contains the path, description, and generated TypeScript signature, so no second lookup is needed. The result signature is the pretty, JSDoc-annotated multiline form: each described input/output field carries its schema `description` as a `/** … */` comment, and constraints TypeScript cannot express ride along as tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`). The inline catalog in the instructions keeps the compact single-line form.
```ts
tools.github.list_issues(input: {
/** Repository owner */
owner: string
/** Cursor from the previous response's pageInfo */
after?: string
/**
* Results per page
* @default 30
*/
perPage?: number
}): Promise<unknown>
```
Result paths are rendered as JavaScript expressions rooted at `tools` (`tools.orders.lookup`, or `tools.context7["resolve-library-id"]` for non-identifier segments), so each `path` is directly usable as the call site. An empty query browses the catalog alphabetically by path; combined with `namespace` (`{ query: "", namespace: "orders" }`) it lists everything in that namespace. A query that names one tool path exactly (canonical path, `tools.`-prefixed path, or rendered JavaScript expression) is treated as a lookup and returns that tool alone.
The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call the exact path as-is; `JSON.parse` string results; return only the needed fields), a `## Rules` section holding only guidance the workflow does not already cover (only listed/search-result tools exist inside `tools`; filter and aggregate collections in code; treat `Promise<unknown>` results as shapeless until verified; run independent calls through `Promise.all`; enumerate `tools` with `Object.keys`/`for...in`; browse a namespace via search when it is advertised), a short `## Syntax` section that assumes standard JavaScript and names only what is unusual (TypeScript annotations stripped; the data-boundary serialization of Date/Map/Set/RegExp) or missing (classes, generators, `for await...of`, `.then`/`.catch`/`.finally`), and the budgeted `## Available tools` catalog. Example call forms use explicit `<namespace>.<tool>`/`<field>` placeholders — never a real or fabricated tool name.
A host cannot define its own `$codemode` top-level namespace.
## Supported Programs
CodeMode executes a deliberately bounded JavaScript subset. It supports:
- Plain data literals, property access, assignment, and destructuring.
- `if`, conditional expressions, `switch`, `for`, `for...of` (arrays, strings, Maps, Sets), `for...in` (own keys of plain objects, index strings of arrays, and namespace/tool names of `tools` references — anything else is an error suggesting `for...of` or `Object.keys`, rather than real JS's surprising behavior of indices for strings and zero iterations for Maps/Sets), `while`, and `do...while`.
- Arrow functions and function declarations with closures, defaults, rest parameters, and destructuring.
- Optional chaining, nullish coalescing, templates, spread (arrays, strings, Maps, Sets), and `try`/`catch`.
- Common array, string, number, `Object`, `Math`, and `JSON` operations. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces and `Object.keys(tools.ns)` the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`.
- `Date``Date.now()`/`Date.parse()`/`Date.UTC()`, `new Date(...)`, the getter methods, and date arithmetic/comparison via the time value. Dates stringify as ISO (`toString` included, for determinism across host timezones).
- Regular expressions — `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout. Function replacers are not supported.
- `Map` and `Set` — construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators).
- First-class promises — an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data a `catch` binding sees, and `Promise.race` interrupts its losing in-flight calls. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic.
- `throw value` and `throw new Error(message)` for explicit program failure. `Error` (and `TypeError`/`RangeError`/`SyntaxError`/`ReferenceError`/`EvalError`/`URIError`) are real constructors, callable with or without `new`; error values are plain `{ name, message }` data that additionally satisfy `instanceof Error` (a specific type matches itself and `Error`, as in JS). Every caught failure — thrown errors, interpreter runtime errors, and tool failures — is `instanceof Error` in a `catch` block; a thrown non-error value (`throw "text"`) is not, matching JS. Caught failures carry the `name` the equivalent real-JS failure would have — `JSON.parse` and invalid regex patterns produce a `SyntaxError` (satisfying `instanceof SyntaxError`), an unknown identifier a `ReferenceError`, assigning to a constant a `TypeError`, a bad `normalize` form a `RangeError`; failures with no specific analogue (including tool failures) are named `"Error"`. `instanceof` also recognizes `Date`, `RegExp`, `Map`, `Set`, `Array`, `Object`, and `Promise`; any other right-hand side is a catchable error.
Inside a program, Date/RegExp/Map/Set values stay live everywhere: the internal data checkpoints (`Object.*` helpers, spread, coercion inputs) preserve the instances, so `Object.values({ d: date })[0].getTime()` and a spread copy of an object holding a Map keep working. Only at the host boundary (final result, tool arguments, `JSON.stringify`) do the four value types serialize exactly as `JSON.stringify` would: a Date becomes its ISO string (`null` when invalid) and RegExp/Map/Set become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`.
It does not expose `eval`, dynamic imports, modules, classes, generators, timers, host globals, prototype mutation, custom promise constructors (`new Promise`), promise chaining (`.then`/`.catch`/`.finally``await` with `try`/`catch` is the supported style), or arbitrary method calls. Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source location when available.
CodeMode is an orchestration language, not a general JavaScript runtime.
## Execution Limits
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. |
| `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.
Pass only the overrides you need:
```ts
const runtime = CodeMode.make({
tools,
limits: {
maxToolCalls: 20,
timeoutMs: 60_000,
},
})
```
Limits are safe integers. `timeoutMs` must be at least `1`; the others may be `0`. Invalid configuration throws a `RangeError` when `CodeMode.make` or `CodeMode.execute` is called. An explicitly `undefined` value is the same as leaving the limit unset.
Exceeding a configured `maxOutputBytes` never fails the execution. An oversized result value is replaced by its truncated serialized text plus an explanatory marker, logs are kept from the start until the remaining budget is exhausted (with a final marker line noting the cut), and the result carries `truncated: true`.
When configured, the timeout interrupts in-flight tool Effects, including eagerly started calls the program has not awaited (their fibers are supervised by the execution). The interpreter yields cooperatively between steps, so the timeout also interrupts pure busy loops (`while (true) {}`) — no separate work budget exists. Tool implementations remain responsible for making their external operations interruptible or independently bounded.
Two interpreter internals are fixed constants rather than knobs: at most 8 tool calls run concurrently, and values crossing a data boundary may nest at most 32 levels deep (deeper values fail as `InvalidDataValue`, which reads better than a native stack-overflow error). Neither is part of the public contract.
## Diagnostics
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. |
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"))
```
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.
## Authority Boundary
CodeMode confines programs to the supplied tool tree, but it does not decide what those tools may do.
The host owns:
- Authentication and authorization.
- Tool selection and immutable scope.
- Credentials and network clients.
- Persistence, idempotency, approval, and durable side effects.
- Logging and redaction policy.
CodeMode owns:
- Parsing and interpreting the supported subset without `eval`.
- Schema boundaries around tool calls.
- Plain-data copying and blocked prototype members.
- Resource limits, call accounting, and normalized diagnostics.
- Model-facing tool discovery and instructions.
A program cannot gain authority through prose or generated code. It can only exercise authority already present in the supplied tools. Do not expose a broad tool and expect the prompt to restrict it.
## Laws
The public contract is guided by these equivalences:
- `CodeMode.execute({ ...options, code })` is equivalent to `CodeMode.make(options).execute(code)`.
- `CodeMode.make(options).agentTool().execute({ code })` is equivalent to `CodeMode.make(options).execute(code)`.
- `CodeMode.make(options).agentTool().description` equals `CodeMode.make(options).instructions()`.
- A tool implementation is not invoked unless its input has decoded successfully.
- A tool result is not visible to the program unless its output has decoded and crossed the plain-data boundary successfully.
- Unknown host failures do not become model-visible diagnostics; `ToolError` is the explicit safe-message channel.
- Host interruption remains interruption rather than an `ExecuteFailure`.
## Non-Goals
- Generic permission prompts or approval workflows.
- Durable pause/resume, replay, or storage adapters.
- Exactly-once external side effects.
- Application authorization or product policy.
- A filesystem or process sandbox for arbitrary JavaScript.
- Compatibility with the full JavaScript language or npm ecosystem.
Applications that need approval or durable consequences should model those above CodeMode and expose only the currently authorized tools.
## Testing
From the package directory:
```sh
bun test
bun run typecheck
```
The direct suite covers public projections, discovery, schema boundaries, diagnostic sanitization, resource limits, tool-call observation, and interruption.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,26 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/codemode",
"version": "0.0.1",
"description": "Effect-native confined code execution over schema-described tools",
"private": true,
"type": "module",
"license": "MIT",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "bun test"
},
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
"typescript": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,27 @@
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"
export type {
AgentToolDefinition,
CodeModeOptions,
CodeModeRuntime,
DataValue,
Diagnostic,
DiagnosticKind,
DiscoveryOptions,
ExecuteFailure,
ExecuteOptions,
ExecuteResult,
ExecuteSuccess,
ExecutionLimits,
ToolCall,
ToolCallStarted,
ToolDescription,
} from "./codemode.js"

View file

@ -0,0 +1,10 @@
/**
* Token estimation for budgeting model-facing text. Copied from
* `@opencode-ai/core/util/token` (chars / 4) so this package stays
* dependency-free; keep the two in sync if the heuristic ever changes.
*/
export * as Token from "./token.js"
const CHARS_PER_TOKEN = 4
export const estimate = (input: string) => Math.max(0, Math.round(input.length / CHARS_PER_TOKEN))

View file

@ -0,0 +1,11 @@
import { Schema } from "effect"
/** Safe operational refusal from a standard tool pack, reported as `ToolFailure`. */
export class ToolError extends Schema.TaggedErrorClass<ToolError>()("ToolError", {
message: Schema.String,
cause: Schema.optionalKey(Schema.Defect()),
}) {}
/** Creates a tool refusal whose message is safe to include in an execution diagnostic. */
export const toolError = (message: string, cause?: unknown): ToolError =>
new ToolError({ message, ...(cause === undefined ? {} : { cause }) })

View file

@ -0,0 +1,746 @@
import { Cause, Effect } from "effect"
import { ToolError, toolError } from "./tool-error.js"
import {
decodeInput as decodeToolInput,
decodeOutput as decodeToolOutput,
identifierSegment,
inputProperties,
inputTypeScript,
isDefinition as isToolDefinition,
outputTypeScript,
type Definition,
} from "./tool.js"
import { estimate } from "./token.js"
import { SandboxDate, SandboxMap, SandboxPromise, SandboxRegExp, SandboxSet } from "./values.js"
export type HostTool<R = never> = (...args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
export type HostTools<R = never> = {
[name: string]: HostTool<R> | Definition<R> | HostTools<R>
}
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> }
? R
: 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 = {
readonly name: string
}
/** Decoded tool call observed immediately before tool execution. */
export type ToolCallStarted = {
readonly index: number
readonly name: string
readonly input: unknown
}
/** Completed tool call observed immediately after tool execution settles. */
export type ToolCallEnded = {
readonly index: number
readonly name: string
readonly input: unknown
readonly durationMs: number
readonly outcome: "success" | "failure"
/** Model-safe failure message; present only when `outcome` is `"failure"`. */
readonly message?: string
}
/** Non-throwing observation hooks fired around each admitted tool call. */
export type ToolCallHooks<R = never> = {
readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect<void, never, R>) | undefined
readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect<void, never, R>) | undefined
}
/** Model-visible description of one schema-backed tool. */
export type ToolDescription = {
readonly path: string
readonly description: string
readonly signature: string
}
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 toolExpression = (path: string) =>
"tools" + path
.split(".")
.map((segment) => identifierSegment.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`)
.join("")
export class ToolReference {
constructor(readonly path: ReadonlyArray<string>) {}
}
/**
* Maximum nesting depth for values crossing a data boundary. Fixed (not a configurable
* limit) purely because it produces a clearer diagnostic than a native stack-overflow
* RangeError would.
*/
const MAX_VALUE_DEPTH = 32
export class ToolRuntimeError extends Error {
constructor(
readonly kind: "UnknownTool" | "InvalidToolInput" | "InvalidToolOutput" | "InvalidDataValue" | "ToolCallLimitExceeded",
message: string,
readonly suggestions: ReadonlyArray<string> = [],
) {
super(message)
this.name = "ToolRuntimeError"
}
}
const isDefinition = <R>(value: HostTool<R> | Definition<R> | HostTools<R>): value is Definition<R> =>
isToolDefinition<R>(value)
const runHost = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, ToolError, R> =>
effect.pipe(
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
const error = Cause.squash(cause)
return Effect.fail(error instanceof ToolError ? error : toolError("Tool execution failed", error))
}),
)
const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"])
export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)
/**
* Validates and copies a value against the plain-data contract (depth, circularity, plain
* objects only, blocked properties, data-only leaves).
*
* Two modes share the walk:
* - **Boundary** (`preserveSandboxValues` false, the default): the hostsandbox boundary
* final results, tool-call arguments, `JSON.stringify`. Sandbox value types serialize
* exactly as JSON.stringify would: Date ISO string (invalid null), RegExp/Map/Set {}.
* - **Intra-sandbox checkpoint** (`preserveSandboxValues` true; see `boundedData` in
* codemode.ts): Date/RegExp/Map/Set instances pass through untouched (treated as leaves,
* contents not walked), so values flowing through `Object.*` helpers, coercion inputs, and
* other in-sandbox checkpoints stay fully usable (`.getTime()`, `.has()`, ...).
*
* Both modes reject un-awaited promises with an await-hinting diagnostic.
*/
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 => {
if (depth > MAX_VALUE_DEPTH) {
throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
}
if (
value === null ||
value === undefined ||
typeof value === "string" ||
typeof value === "boolean" ||
// NaN/Infinity are allowed to exist as in-sandbox intermediates (matching real JS and a real
// engine) so defensive guards like `Number.isNaN(x)` / `parseInt(x) || 0` can run. They are
// normalized to `null` when the value leaves the sandbox — see copyOut — exactly as
// JSON.stringify already does at any tool boundary.
typeof value === "number"
) {
return value
}
if (typeof value !== "object") {
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`)
}
// An un-awaited promise never crosses a data checkpoint as `{}`; the diagnostic tells the
// model exactly how to fix the program instead.
if (value instanceof SandboxPromise) {
throw new ToolRuntimeError(
"InvalidDataValue",
`${label} contains an un-awaited Promise; await tool calls (e.g. \`const result = await tools.ns.tool(...)\`) before using their results.`,
)
}
if (preserveSandboxValues) {
// 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) {
return value
}
// Host instances cannot normally reach an intra-sandbox checkpoint (tool results cross
// the boundary first), but wrap them defensively rather than degrading to JSON forms.
if (value instanceof Date) return new SandboxDate(value.getTime())
if (value instanceof RegExp) return new SandboxRegExp(value.source, value.flags)
if (value instanceof Map) {
const wrapped = new SandboxMap()
for (const [key, item] of value.entries()) {
wrapped.map.set(copyBounded(key, label, depth + 1, seen, true), copyBounded(item, label, depth + 1, seen, true))
}
return wrapped
}
if (value instanceof Set) {
const wrapped = new SandboxSet()
for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true))
return wrapped
}
}
// Sandbox value types (and their host counterparts, which a host tool may legitimately
// return) serialize exactly as JSON.stringify would at the data boundary: a Date is its
// toJSON() ISO string (invalid -> null), and RegExp/Map/Set have no JSON form beyond {}.
if (value instanceof SandboxDate) {
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
}
if (value instanceof Date) {
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
) {
return Object.create(null) as SafeObject
}
if (seen.has(value)) {
throw new ToolRuntimeError("InvalidDataValue", `${label} contains a circular value.`)
}
seen.add(value)
if (Array.isArray(value)) {
const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveSandboxValues))
seen.delete(value)
return copied
}
const prototype = Object.getPrototypeOf(value)
if (prototype !== Object.prototype && prototype !== null) {
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain plain objects only.`)
}
const copied: SafeObject = Object.create(null) as SafeObject
for (const [key, item] of Object.entries(value)) {
if (isBlockedMember(key)) {
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
}
copied[key] = copyBounded(item, label, depth + 1, seen, preserveSandboxValues)
}
seen.delete(value)
return copied
}
export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
if (value === undefined && undefinedAsNull) return null
// Normalize non-finite numbers to null as the value crosses out of the sandbox (final return
// and tool-call arguments both funnel through here), matching JSON semantics — NaN/Infinity
// have no JSON representation, so JSON.stringify would produce null anyway.
if (typeof value === "number" && !Number.isFinite(value)) {
return null
}
if (Array.isArray(value)) {
return value.map((item) => copyOut(item, undefinedAsNull))
}
if (value !== null && typeof value === "object" && !(value instanceof ToolReference)) {
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, copyOut(item, undefinedAsNull)]))
}
return value
}
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]
if (isDefinition(value)) entries.push({ path: next.join("."), definition: value })
else if (typeof value !== "function") entries.push(...definitions(value, next))
}
return entries
}
const describeDefinition = <R>(path: string, definition: Definition<R>): ToolDescription => ({
path,
description: definition.description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(definition)}): Promise<${outputTypeScript(definition)}>`,
})
const visibleDefinitions = <R>(tools: HostTools<R>) =>
definitions(tools).flatMap(({ path, definition }) => {
const description = describeDefinition(path, definition)
return [{ path, definition, description }]
})
export const catalog = <R>(tools: HostTools<R>): ReadonlyArray<ToolDescription> =>
visibleDefinitions(tools).map(({ description }) => description)
export type DiscoveryPlan = {
readonly catalog: ReadonlyArray<ToolDescription>
readonly instructions: string
readonly searchIndex: ReadonlyArray<SearchEntry>
}
export type SearchEntry = {
readonly description: ToolDescription
/**
* JSDoc-annotated multiline signature shown on search-result items; the compact
* single-line form (inline catalog lines) stays in `description.signature`.
*/
readonly signature: string
/** Top-level namespace (first path segment), matched by the search `namespace` option. */
readonly namespace: string
/** Lowercased path + description + input property names/descriptions, for substring matching. */
readonly searchText: string
}
/**
* Split a query into lowercased search terms. camelCase boundaries are split
* (`resolveLibrary` -> `resolve library`) and every non-alphanumeric character is a
* separator, so `resolve-library-id`, `resolveLibraryId`, and `resolve library id` all
* tokenize alike. Empties and the `*` wildcard are dropped.
*/
const tokenize = (query: string): Array<string> =>
query
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((term) => term.length > 0 && term !== "*")
/**
* A term plus its naive singular variants (trailing "s"/"es" stripped), so a plural
* query term ("issues") still matches indexed text that only carries the singular
* ("issue"). Matching is one-directional substring containment, so the variants are
* needed only on the query side; scoring weights are unchanged each field check
* passes when ANY form matches.
*/
const termForms = (term: string): Array<string> => {
const forms = [term]
if (term.endsWith("es") && term.length > 3) forms.push(term.slice(0, -2))
if (term.endsWith("s") && term.length > 2) forms.push(term.slice(0, -1))
return forms
}
const firstLine = (text: string) => text.split("\n", 1)[0]!.trim()
/** One-line description used on inline catalog lines; the full text stays in search results. */
const brief = (text: string, max = 120) => {
const line = firstLine(text)
return line.length > max ? line.slice(0, max - 1) + "…" : line
}
const catalogLine = (tool: ToolDescription) => {
const description = brief(tool.description)
return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}`
}
const toSearchEntry = <R>(path: string, definition: Definition<R>, description: ToolDescription): SearchEntry => ({
description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`,
namespace: path.split(".", 1)[0]!,
searchText: [
path,
definition.description,
...inputProperties(definition).flatMap(({ name, description: property }) =>
property === undefined ? [name] : [name, property]),
].join("\n").toLowerCase(),
})
/** The runtime search index over every described tool. Search is always registered. */
export const searchIndex = <R>(tools: HostTools<R>): ReadonlyArray<SearchEntry> =>
visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description))
export const assertValidTools = <R>(tools: HostTools<R>): void => {
if (Object.hasOwn(tools, reservedNamespace)) {
throw new Error(`Tool namespace '${reservedNamespace}' is reserved for CodeMode discovery tools.`)
}
}
/**
* Budgeted catalog: every namespace is always listed with its tool count; full call
* signatures are inlined against the `maxInlineCatalogTokens` budget (estimated tokens,
* chars/4) round-robin across namespaces in each round (namespaces alphabetical), every
* namespace still holding un-inlined tools attempts to place its next-cheapest line, and
* a namespace whose next line does not fit is done while the others keep going so every
* namespace gets some representation before any namespace gets everything. The section
* states exactly how comprehensive it is overall (COMPLETE vs PARTIAL) and per
* namespace. Namespace stub lines are never budgeted: every namespace appears with its
* tool count even at budget 0.
*/
export const discoveryPlan = <R>(
tools: HostTools<R>,
maxInlineCatalogTokens = defaultMaxInlineCatalogTokens,
): DiscoveryPlan => {
if (!Number.isSafeInteger(maxInlineCatalogTokens) || maxInlineCatalogTokens < 0) {
throw new RangeError("discovery.maxInlineCatalogTokens must be a non-negative safe integer")
}
const visible = visibleDefinitions(tools)
const described = visible.map(({ description }) => description)
const namespaces = new Map<string, Array<ToolDescription>>()
for (const tool of described) {
const [namespace = tool.path] = tool.path.split(".")
const group = namespaces.get(namespace) ?? []
group.push(tool)
namespaces.set(namespace, group)
}
const ordered = [...namespaces].sort(([left], [right]) => left.localeCompare(right))
// Select which signatures fit the budget before emitting, so the list can state
// exactly how comprehensive it is. Round-robin fairness: in each round (namespaces
// alphabetical), every namespace still holding un-inlined tools tries to place its
// next-cheapest line against the shared budget; a namespace whose next line does not
// fit is done — the others keep going — so every namespace gets some representation
// before any namespace gets everything.
const selections = ordered.map(([namespace, group]) => ({
namespace,
picked: new Set<ToolDescription>(),
queue: [...group].sort(
(left, right) => estimate(catalogLine(left)) - estimate(catalogLine(right)) || left.path.localeCompare(right.path),
),
}))
let used = 0
let active = selections.filter((selection) => selection.queue.length > 0)
while (active.length > 0) {
const stillActive: typeof active = []
for (const selection of active) {
const tool = selection.queue[0]!
const cost = estimate(catalogLine(tool))
if (used + cost > maxInlineCatalogTokens) continue
selection.queue.shift()
selection.picked.add(tool)
used += cost
if (selection.queue.length > 0) stillActive.push(selection)
}
active = stillActive
}
const shown = new Map<string, ReadonlySet<ToolDescription>>(
selections.map(({ namespace, picked }) => [namespace, picked]),
)
const totalShown = selections.reduce((total, { picked }) => total + picked.size, 0)
const complete = totalShown === described.length
const empty = described.length === 0
// Section order is deliberate: workflow first (the top is the least likely part of a long
// description to be truncated or skimmed away), then rules, then syntax, with the budgeted
// catalog at the bottom. Example call forms use explicit `<namespace>.<tool>` placeholders —
// never a real or fabricated tool name.
const intro = [
"Write a CodeMode program to answer the request. Return code only.",
empty
? "Execute JavaScript in a confined runtime."
: complete
? "Execute JavaScript in a confined runtime. Inside this program, `tools` contains only the host-provided tools listed below; surrounding agent tools are not available unless listed here."
: "Execute JavaScript in a confined runtime. Inside this program, `tools` contains only the host-provided tools listed or searchable below; surrounding agent tools are not available unless listed here.",
]
// The search step exists only when search is advertised (PARTIAL catalog); a COMPLETE
// catalog already shows every signature, so step 1 picks from the list instead.
const workflow = empty
? []
: [
"",
"## Workflow",
"",
...(complete
? [
"1. Pick a tool from the list under `## Available tools` — each line is the exact call signature; use it as-is rather than guessing segments.",
"2. Call it using the exact signature shown: `const res = await tools.<namespace>.<tool>(input)` — bracket notation may appear for names that are not JavaScript identifiers.",
'3. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` — most tools return JSON as a string.',
"4. Return only the fields you need: `return { <field>: data.<field> }` — raw payloads get truncated and waste context.",
]
: [
'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.',
"2. Read the matches: each item is `{ path, description, signature }` — read the description before using an unfamiliar tool.",
"3. Call it with the result's `path` as-is (never guess segments): `const res = await tools.<namespace>.<tool>(input)` — bracket notation may appear for names that are not JavaScript identifiers.",
'4. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` — most tools return JSON as a string.',
"5. Return only the fields you need: `return { <field>: data.<field> }` — raw payloads get truncated and waste context.",
]),
]
const rules = empty
? []
: [
"",
"## Rules",
"",
complete
? "- Only tools listed here are available inside `tools`; tools from the surrounding agent/runtime are not implicitly exposed."
: "- Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`; tools from the surrounding agent/runtime are not implicitly exposed.",
"- Filter, aggregate, and transform collections in code — never return them raw or call a tool per item across messages.",
"- 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>" })`.']),
]
const syntax = [
"",
"## Syntax",
"",
"Standard modern JavaScript 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.",
"TypeScript type annotations are allowed and stripped before execution (decorators are not supported).",
"Not supported (each fails with a message naming the alternative): classes, generators, for await...of, .then/.catch/.finally (use await with try/catch).",
"Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.",
]
const toolSection: Array<string> = [""]
if (empty) {
toolSection.push("## Available tools", "", "No tools are currently available.")
} else {
toolSection.push(
complete
? "## Available tools (COMPLETE list — every tool is shown below with its full call signature)"
: `## Available tools (PARTIAL — ${totalShown} of ${described.length} shown; find the rest with tools.$codemode.search)`,
"",
)
for (const [namespace, group] of ordered) {
const picked = shown.get(namespace)!
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`
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}`,
)
}
}
const lines = [
...intro,
...workflow,
...rules,
...syntax,
...toolSection,
]
return {
catalog: described,
instructions: lines.join("\n"),
searchIndex: visible.map(({ path, definition, description }) => toSearchEntry(path, definition, description)),
}
}
/**
* The enumerable names at one node of the host tool tree namespace names at the root,
* tool/namespace names below powering `Object.keys(tools)` and `for...in` over tool
* references. A callable tool is a leaf and enumerates as `[]` (like `Object.keys` of a
* 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> => {
// 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)) {
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."],
)
}
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
}
if (typeof value === "function" || isDefinition(value)) return []
return Object.keys(value)
}
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."] : [])
}
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
}
if (typeof value !== "function" && !isDefinition(value)) {
throw new ToolRuntimeError("UnknownTool", `Tool '${path.join(".")}' is not callable.`)
}
return value
}
export type ToolRuntime<R = never> = {
readonly root: ToolReference
readonly calls: Array<ToolCall>
readonly invoke: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
/** Enumerable namespace/tool names at one node of the host tool tree; see `namespaceKeys`. */
readonly keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
}
const failureMessage = (error: unknown): string =>
error instanceof ToolError || error instanceof ToolRuntimeError ? error.message : "Tool execution failed"
export const make = <R>(
tools: HostTools<R>,
/** Undefined means unlimited tool calls. */
maxToolCalls: number | undefined,
hooks?: ToolCallHooks<R>,
searchIndex?: ReadonlyArray<SearchEntry>,
): ToolRuntime<R> => {
const calls: Array<ToolCall> = []
const searchEnabled = searchIndex !== undefined
// Wraps the settling portion of a tool call so onToolCallEnd observes success and failure
// symmetrically. Interruption (e.g. the execution timeout) fires neither outcome.
const observeEnd = <A, E>(effect: Effect.Effect<A, E, R>, call: ToolCallStarted): Effect.Effect<A, E, R> => {
const onEnd = hooks?.onToolCallEnd
if (onEnd === undefined) return effect
const startedAt = Date.now()
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) })),
)
}
const decodeOutput = (value: unknown, name: string) =>
Effect.try({
try: () => copyIn(value, `Result from tool '${name}'`),
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
})
const recordCall = (call: ToolCall): void => {
if (maxToolCalls !== undefined && calls.length >= maxToolCalls) {
throw new ToolRuntimeError("ToolCallLimitExceeded", `Execution exceeded its tool-call limit of ${maxToolCalls}.`)
}
calls.push(call)
}
return {
root: new ToolReference([]),
calls,
keys: (path) => namespaceKeys(tools, path, searchEnabled),
invoke: (path, args) =>
Effect.gen(function*() {
const name = path.join(".")
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`)))
const call = { name }
const recordAndObserve = (input: unknown) =>
Effect.sync(() => {
recordCall(call)
return calls.length - 1
}).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void))
if (name === "$codemode.search") {
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 }.")
}
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.")
}
if (request.namespace !== undefined && typeof request.namespace !== "string") {
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.")
}
const query = typeof request.query === "string" ? request.query : ""
const namespace = typeof request.namespace === "string" ? request.namespace : undefined
const index = yield* recordAndObserve(request)
return yield* observeEnd(
Effect.try({
try: () => {
const limit = typeof request.limit === "number" ? request.limit : defaultSearchLimit
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 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,
)
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)
// 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,
// JSDoc-annotated form (schema descriptions and constraints ride along as
// field comments).
const items = ranked.slice(0, limit).map(({ description, signature }) => ({
...description,
path: toolExpression(description.path),
signature,
}))
return copyIn({ items, total: ranked.length }, "Result from tool '$codemode.search'")
},
catch: (cause) => cause,
}),
{ index, name, input: request },
)
}
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.`)
describedInput = yield* Effect.try({
try: () => decodeToolInput(tool, externalArgs[0]),
catch: (cause) => new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`),
})
}
const input = isDefinition(tool) ? describedInput : externalArgs
const index = yield* recordAndObserve(input)
const currentCall = { index, name, input }
if (isDefinition(tool)) {
return yield* observeEnd(
Effect.gen(function*() {
const raw = yield* runHost(Effect.suspend(() => tool.run(describedInput)))
const result = yield* Effect.try({
try: () => decodeToolOutput(tool, raw),
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
})
return yield* decodeOutput(result, name)
}),
currentCall,
)
}
return yield* observeEnd(
Effect.gen(function*() {
return yield* decodeOutput(yield* runHost(Effect.suspend(() => tool(...externalArgs))), name)
}),
currentCall,
)
}),
}
}
export * as ToolRuntime from "./tool-runtime.js"

View file

@ -0,0 +1,333 @@
import { Effect, Schema } from "effect"
/**
* JSON Schema subset accepted for render-only tool schemas.
*
* A JSON-Schema-described side of a tool is used to generate the model-visible TypeScript
* signature only CodeMode performs no validation against it. This is the natural shape for
* adapter-provided tools (e.g. MCP definitions) whose schemas arrive as JSON Schema documents.
*/
export type JsonSchema = {
readonly type?: string | ReadonlyArray<string>
readonly enum?: ReadonlyArray<unknown>
readonly const?: unknown
readonly anyOf?: ReadonlyArray<JsonSchema>
readonly oneOf?: ReadonlyArray<JsonSchema>
readonly properties?: Readonly<Record<string, JsonSchema>>
readonly required?: ReadonlyArray<string>
readonly items?: JsonSchema
readonly additionalProperties?: boolean | JsonSchema
readonly description?: string
readonly default?: unknown
readonly format?: string
readonly deprecated?: boolean
readonly minItems?: number
readonly maxItems?: number
readonly $ref?: string
readonly $defs?: Readonly<Record<string, JsonSchema>>
readonly definitions?: Readonly<Record<string, JsonSchema>>
}
/** Either a validating Effect Schema or a render-only JSON Schema document. */
export type ToolSchema = Schema.Decoder<unknown> | JsonSchema
/** Schema-backed tool definition consumed by a CodeMode tool tree. */
export type Definition<R = never> = {
readonly _tag: "CodeModeTool"
readonly description: string
readonly input: ToolSchema
readonly output: ToolSchema | undefined
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, R>
}
/** The value `run` receives: the decoded type for Effect Schemas, `unknown` for JSON Schemas. */
export type InputType<S> = S extends Schema.Decoder<unknown> ? S["Type"] : unknown
/** The value `run` returns: the encoded type for Effect Schemas, `unknown` otherwise. */
export type ResultType<S> = S extends Schema.Decoder<unknown> ? S["Encoded"] : unknown
/** Options for defining one CodeMode tool. */
export type Options<I extends ToolSchema, O extends ToolSchema | undefined, R = never> = {
readonly description: string
readonly input: I
readonly output?: O
readonly run: (input: InputType<I>) => Effect.Effect<ResultType<O>, unknown, 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 renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown"
/**
* Bare TypeScript identifier usable unquoted as an object key (and, in the tool runtime,
* with dot access as a tool-path segment). Anything else must be quoted/bracketed.
*/
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 effectNumberSentinel = (schema: JsonSchema) =>
schema.type === "string" && Array.isArray(schema.enum) && schema.enum.length === 1 &&
(schema.enum[0] === "NaN" || schema.enum[0] === "Infinity" || schema.enum[0] === "-Infinity")
/**
* Recursion ceiling for schema rendering. Object, array, and union recursion all increment
* depth, so this bounds every recursion path pathological or structurally cyclic schemas
* degrade to `unknown` instead of overflowing the stack (rendering must never throw).
*/
const MAX_RENDER_DEPTH = 8
type RenderContext = {
readonly definitions: Readonly<Record<string, JsonSchema>>
/** Indented, JSDoc-annotated multiline rendering (search results); compact single line otherwise. */
readonly pretty: boolean
}
/**
* Schema constraints a TypeScript type cannot express natively but a model benefits from,
* surfaced as JSDoc tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`).
*/
const docTags = (schema: JsonSchema): Array<string> => {
const tags: Array<string> = []
if (schema.deprecated === true) tags.push("@deprecated")
if (schema.default !== undefined) {
try {
const rendered = JSON.stringify(schema.default)
if (rendered !== undefined) tags.push(`@default ${rendered}`)
} catch {
// unserializable default: skip rather than emit a broken tag
}
}
if (typeof schema.format === "string") tags.push(`@format ${schema.format}`)
if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`)
if (typeof schema.maxItems === "number") tags.push(`@maxItems ${schema.maxItems}`)
return tags
}
/**
* Format a schema `description` plus `tags` as a JSDoc comment at the given indent,
* preserving multi-line text (a single line stays `/** … *\/`; multiple lines become a
* `*`-prefixed block). `*\/` is neutralized so nothing can close the comment early, and
* blank leading/trailing lines are trimmed. Returns "" (else a trailing newline) so
* callers can prepend it directly to the field line.
*/
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+$/, ""))
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 ""
if (lines.length === 1) return `${pad}/** ${lines[0]} */\n`
const body = lines.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n")
return `${pad}/**\n${body}\n${pad} */\n`
}
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()
if (!name || !ctx.definitions[name]) return name ?? "unknown"
if (seen.has(name)) return name // recursive type: reference by name rather than loop
return renderSchema(ctx.definitions[name], ctx, depth, new Set([...seen, name]))
}
if (schema.const !== undefined) return renderLiteral(schema.const)
if (schema.enum) return schema.enum.map(renderLiteral).join(" | ")
const alternatives = schema.anyOf ?? schema.oneOf
if (alternatives) {
// Effect's number schema emits `anyOf: [{ type: "number" }, { const: "NaN" },
// { const: "Infinity" }, { const: "-Infinity" }]`. Collapse only that artifact;
// real JSON Schema unions such as `string | number` or `number | null` must keep
// every branch.
if (
alternatives.some((item) => item.type === "number") &&
alternatives.every((item) => item.type === "number" || effectNumberSentinel(item))
) 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
) {
return "{}"
}
return alternatives.map((item) => renderSchema(item, ctx, depth + 1, seen)).join(" | ")
}
if (Array.isArray(schema.type)) {
return schema.type.map((item) => renderSchema({ type: item }, ctx, depth + 1, seen)).join(" | ")
}
if (schema.type === "string") return "string"
if (schema.type === "number" || schema.type === "integer") return "number"
if (schema.type === "boolean") return "boolean"
if (schema.type === "null") return "null"
if (schema.type === "array") return `Array<${renderSchema(schema.items ?? {}, ctx, depth + 1, seen)}>`
if (schema.type === "object" || schema.properties) {
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 field = ([name, value]: readonly [string, JsonSchema]) =>
`${renderKey(name)}${required.has(name) ? "" : "?"}: ${renderSchema(value, ctx, depth + 1, seen)}`
if (!ctx.pretty) {
const fields = properties.map(field)
if (indexType !== undefined) fields.push(`[key: string]: ${indexType}`)
return fields.length === 0 ? "{}" : `{ ${fields.join("; ")} }`
}
// 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)}`)
if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType}`)
return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}`
}
return "unknown"
}
export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false): string => {
try {
const visible = decoded ? Schema.toType(schema) : schema
const document = Schema.toJsonSchemaDocument(visible) as {
readonly schema: JsonSchema
readonly definitions?: Readonly<Record<string, JsonSchema>>
}
return renderSchema(document.schema, { definitions: document.definitions ?? {}, pretty })
} catch {
return "unknown"
}
}
/** Renders a raw JSON Schema document as a TypeScript type string. */
export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => {
try {
return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty })
} catch {
return "unknown"
}
}
/** One input property of a tool, extracted best-effort from its input schema. */
export type InputProperty = {
readonly name: string
readonly description: string | undefined
readonly required: boolean
}
/**
* The property names, descriptions, and required flags of a tool's input schema the raw
* material for search text. Best-effort: Effect Schemas go through their
* JSON Schema document (the same emission signature rendering uses); JSON Schemas are read
* directly, resolving a trivial top-level `$ref` into `$defs`/`definitions` when present.
* Anything unresolvable yields `[]` (search falls back to path + description).
*/
export const inputProperties = <R>(definition: Definition<R>): Array<InputProperty> => {
try {
const document = isEffectSchema(definition.input)
? (Schema.toJsonSchemaDocument(definition.input) as {
readonly schema: JsonSchema
readonly definitions?: Readonly<Record<string, JsonSchema>>
})
: {
schema: definition.input,
definitions: { ...(definition.input.definitions ?? {}), ...(definition.input.$defs ?? {}) },
}
const definitions = document.definitions ?? {}
let schema = document.schema
if (schema.$ref !== undefined) {
const name = schema.$ref.split("/").pop()
const resolved = name === undefined ? undefined : definitions[name]
if (resolved === undefined) return []
schema = resolved
}
const required = new Set(schema.required ?? [])
return Object.entries(schema.properties ?? {}).map(([name, value]) => ({
name,
description: typeof value.description === "string" ? value.description : undefined,
required: required.has(name),
}))
} catch {
return []
}
}
/**
* The model-visible TypeScript type of a tool's input. `pretty` renders an indented
* multiline block with schema descriptions and constraints as JSDoc comments on the
* 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)
/**
* The model-visible TypeScript type of a tool's result; tools without an output schema
* return `unknown`. `pretty` renders the JSDoc-annotated multiline form, as for inputs.
*/
export const outputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
definition.output === undefined
? "unknown"
: isEffectSchema(definition.output)
? toTypeScript(definition.output, true, pretty)
: jsonSchemaToTypeScript(definition.output, pretty)
/**
* Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure);
* JSON-Schema-described inputs pass through unvalidated (render-only).
*/
export const decodeInput = <R>(definition: Definition<R>, value: unknown): unknown =>
isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value
/**
* Decodes a tool result before it is exposed to the program. Effect Schemas validate and
* transform (throwing on failure); JSON Schema outputs and tools without an output schema pass
* the host value through unchanged.
*/
export const decodeOutput = <R>(definition: Definition<R>, value: unknown): unknown =>
definition.output !== undefined && isEffectSchema(definition.output)
? Schema.decodeUnknownSync(definition.output)(value)
: value
/**
* Defines one schema-described tool available to a CodeMode program through `tools.*`.
*
* `input` and `output` each accept a validating Effect Schema or a render-only JSON Schema
* document. Effect Schema input is decoded before `run` is invoked, and `run` returns the
* encoded representation of an Effect Schema `output`, which CodeMode decodes before returning
* it to the program. JSON Schemas only shape the model-visible signature; values pass through
* unvalidated. `output` is optional without it the signature advertises `unknown` and the
* host result is exposed as-is. The host tool remains responsible for authorization and
* durable side-effect handling.
*
* @example
* ```ts
* const lookup = Tool.make({
* description: "Look up an order",
* input: Schema.Struct({ id: Schema.String }),
* output: Schema.Struct({ status: Schema.String }),
* run: ({ id }) => Effect.succeed({ status: "open" }),
* })
*
* const fromJsonSchema = Tool.make({
* description: "Call an adapter-described tool",
* input: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
* run: (input) => callHost(input),
* })
* ```
*/
export const make = <I extends ToolSchema, const O extends ToolSchema | undefined = undefined, R = never>(
options: Options<I, O, R>,
): Definition<R> => ({
_tag: "CodeModeTool",
description: options.description,
input: options.input,
output: options.output,
run: (input) => options.run(input as InputType<I>),
})
/** Constructors for schema-backed tools exposed inside CodeMode programs. */
export const Tool = { make, isDefinition }

View file

@ -0,0 +1,52 @@
// Sandbox value types backed by host primitives. They live in their own module so both the
// interpreter (codemode.ts) and the data boundary (tool-runtime.ts) can reference them without
// a circular import. All four are opaque runtime values inside a program; when a value crosses
// the sandbox boundary (final result, tool arguments, JSON.stringify) they serialize exactly as
// JSON.stringify would: Date -> ISO string (invalid -> null), RegExp/Map/Set -> {}.
import type { Effect, Fiber } from "effect"
/**
* A first-class promise value produced by an un-awaited tool call (or by
* `Promise.resolve`/`Promise.reject`). Tool-call promises are eager: the call runs on a fiber
* forked at call time, and `await` observes that fiber's settlement. Promises are opaque
* runtime references `typeof` reports `"object"` (as in real JS), operators reject them, and
* they cannot cross a data boundary un-awaited (the boundary raises an await-hinting
* diagnostic instead of serializing `{}`).
*/
export class SandboxPromise {
/** Set when Promise.race interrupts this promise's in-flight call after another entry wins. */
interrupted = false
constructor(
/** Backing fiber for an eagerly started tool call; undefined for resolve/reject promises. */
readonly fiber: Fiber.Fiber<unknown, unknown> | undefined,
/** Immediate settlement for fiberless promises (Promise.resolve / Promise.reject). */
readonly immediate?: Effect.Effect<unknown, unknown>,
) {}
}
/** An immutable instant, backed by an epoch-milliseconds time value (NaN = Invalid Date). */
export class SandboxDate {
constructor(readonly time: number) {}
}
/** A regular expression backed by the host engine; `lastIndex` state lives on the host regex. */
export class SandboxRegExp {
readonly regex: RegExp
constructor(pattern: string, flags: string) {
this.regex = new RegExp(pattern, flags)
}
}
/** A keyed collection with SameValueZero keys. */
export class SandboxMap {
readonly map = new Map<unknown, unknown>()
}
/** A unique-value collection. */
export class SandboxSet {
readonly set = new Set<unknown>()
}
export const isSandboxValue = (value: unknown): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet =>
value instanceof SandboxDate || value instanceof SandboxRegExp || value instanceof SandboxMap || value instanceof SandboxSet

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,147 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
// Key enumeration: Object.keys and for...in share one surface over plain objects, arrays
// (index strings), and tool references (namespace/tool names from the host tool tree), so a
// model can discover what it may call instead of guessing names from the instructions. The
// motivating transcript: `Object.keys(tools)` failed with the generic plain-objects-only
// message and `for (const key in tools)` was unsupported syntax, forcing blind guesses.
const echo = (description: string) =>
Tool.make({
description,
input: Schema.Struct({ value: Schema.String }),
output: Schema.String,
run: ({ value }) => Effect.succeed(value),
})
const tools = {
github: { list_issues: echo("List issues"), get_issue: echo("Get one issue") },
memory: { search: echo("Search memory") },
playwright: { navigate: echo("Navigate somewhere") },
}
const run = (code: string) => Effect.runPromise(CodeMode.execute({ tools, code }))
const value = async (code: string) => {
const result = await run(code)
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
const error = async (code: string) => {
const result = await run(code)
if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
return result.error
}
describe("Object.keys over tool references", () => {
test("enumerates top-level namespaces (the transcript program)", async () => {
expect(await value(`
const namespaces = Object.keys(tools)
return { namespaces, count: namespaces.length }
`)).toEqual({ namespaces: ["github", "memory", "playwright"], count: 3 })
})
test("enumerates tool names at a nested namespace", async () => {
expect(await value(`return Object.keys(tools.github)`)).toEqual(["list_issues", "get_issue"])
})
test("a callable tool is a leaf and enumerates as []", async () => {
expect(await value(`return Object.keys(tools.github.list_issues)`)).toEqual([])
})
test("the virtual discovery namespace enumerates its callable surface", async () => {
expect(await value(`return Object.keys(tools.$codemode)`)).toEqual(["search"])
})
test("an unknown namespace is an UnknownTool error pointing at the discovery idioms", async () => {
const failure = await error(`return Object.keys(tools.nonexistent)`)
expect(failure.kind).toBe("UnknownTool")
expect(failure.message).toContain("Unknown tool namespace 'nonexistent'")
expect(failure.suggestions?.join(" ")).toContain("Object.keys(tools)")
})
test("Object.values/entries on a tool reference explain the working idioms", async () => {
for (const method of ["values", "entries"] as const) {
const failure = await error(`return Object.${method}(tools)`)
expect(failure.kind).toBe("InvalidDataValue")
expect(failure.message).toContain(
`Object.${method}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or tools.$codemode.search({ query }) for signatures.`,
)
}
const nested = await error(`return Object.entries(tools.github)`)
expect(nested.message).toContain("Use Object.keys(tools) for names")
})
})
describe("Object.keys over arrays", () => {
test("returns index strings, like JS", async () => {
expect(await value(`return Object.keys(["a", "b", "c"])`)).toEqual(["0", "1", "2"])
expect(await value(`return Object.keys([])`)).toEqual([])
})
test("objects keep their own enumerable keys", async () => {
expect(await value(`return Object.keys({ a: 1, b: 2 })`)).toEqual(["a", "b"])
})
test("non-object inputs still fail clearly", async () => {
const failure = await error(`return Object.keys("nope")`)
expect(failure.message).toContain("Object.keys expects a data object or array")
})
})
describe("for...in", () => {
test("iterates own enumerable keys of a plain object with break/continue", async () => {
expect(await value(`
const seen = []
for (const key in { a: 1, b: 2, c: 3, d: 4 }) {
if (key === "b") continue
if (key === "d") break
seen.push(key)
}
return seen
`)).toEqual(["a", "c"])
})
test("iterates index strings over arrays", async () => {
expect(await value(`
const indexes = []
for (const i in ["x", "y", "z"]) {
if (i === "2") break
indexes.push(i)
}
return indexes
`)).toEqual(["0", "1"])
})
test("supports let declarations and bare identifiers", async () => {
expect(await value(`
let last = ""
for (let key in { a: 1, b: 2 }) last = key
return last
`)).toBe("b")
expect(await value(`
let key = "before"
for (key in { only: 1 }) {}
return key
`)).toBe("only")
})
test("enumerates namespaces and tools from the host tool tree", async () => {
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"])
})
test("unsupported values fail with a hint at for...of and Object.keys", async () => {
for (const expression of [`"text"`, "new Map([[1, 2]])", "new Set([1])", "42", "null"]) {
const failure = await error(`for (const key in ${expression}) {}; return "no"`)
expect(failure.message).toContain("for...in requires a plain object, array, or tools reference")
expect(failure.message).toContain("Use for...of for arrays/strings/Maps/Sets, or Object.keys(value)")
}
})
})

View file

@ -0,0 +1,380 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
import { ToolRuntime } from "../src/tool-runtime.js"
// Runs a CodeMode program with no host tools and returns the ExecuteResult. These tests pin the
// JS-parity behaviors for the "99% of ordinary defensive JavaScript just works" goal: cases where
// a strict interpreter would throw but idiomatic JS yields undefined / succeeds.
//
// Note on the result boundary: this package normalizes a bare `undefined` result to `null` when
// it crosses out of the sandbox (results are JSON data), so tests asserting an in-sandbox
// `undefined` read check `=== undefined` inside the program and `null` at the boundary.
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const value = async (code: string) => {
const result = await run(code)
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
const error = async (code: string) => {
const result = await run(code)
if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
return result.error
}
describe("H2: string property access reads as undefined (not a throw)", () => {
test("unknown property on a string is undefined", async () => {
expect(await value(`const s = "hi"; return s.login === undefined`)).toBe(true)
expect(await value(`const s = "hi"; return s.login`)).toBeNull()
})
test("optional chaining + fallback on a string does not throw", async () => {
expect(await value(`const s = "hi"; return s?.login ?? "fallback"`)).toBe("fallback")
})
test("the real MCP pattern: result is a JSON string, defensive read falls through", async () => {
// me.result is a string; me.result?.login is undefined, so we fall back to the raw string.
expect(await value(`const me = { result: '{"login":"x"}' }; return me.result?.login ?? me.result`)).toBe(
'{"login":"x"}',
)
})
test("unknown property on a number is undefined", async () => {
expect(await value(`return (5).foo ?? "n"`)).toBe("n")
})
test("supported string methods still work", async () => {
expect(await value(`return "AB".toLowerCase()`)).toBe("ab")
expect(await value(`return "hello".length`)).toBe(5)
})
})
describe("H3: array property access reads as undefined (not a throw)", () => {
test("unknown property on an array is undefined", async () => {
expect(await value(`return [1,2,3].foo === undefined`)).toBe(true)
expect(await value(`return [1,2,3].foo`)).toBeNull()
})
test("optional chaining on an array does not throw", async () => {
expect(await value(`return [1,2,3]?.foo ?? "fb"`)).toBe("fb")
})
test("unknown property reads stay undefined for methods CodeMode does not implement", async () => {
expect(await value(`return [1,2,3].toSpliced === undefined`)).toBe(true)
})
test("supported array methods and indexing still work", async () => {
expect(await value(`return [1,2,3].map(x => x + 1)`)).toEqual([2, 3, 4])
expect(await value(`return [1,2,3][9] === undefined`)).toBe(true)
expect(await value(`return [1,2,3][9]`)).toBeNull()
})
})
describe("H6: object spread of null/undefined is a no-op", () => {
test("spreading null is a no-op", async () => {
expect(await value(`const o = null; return { ...o, a: 1 }`)).toEqual({ a: 1 })
})
test("spreading an absent argument merges cleanly", async () => {
expect(await value(`function f(opts){ return { ...opts, a: 1 } } return f(undefined)`)).toEqual({ a: 1 })
})
test("spreading a real object still works", async () => {
expect(await value(`const o = { a: 1 }; return { ...o, b: 2 }`)).toEqual({ a: 1, b: 2 })
})
test("spreading an array into an object still errors", async () => {
const err = await error(`return { ...[1,2], a: 1 }`)
expect(err.kind).toBe("InvalidDataValue")
})
})
describe("H4: typeof on an undeclared identifier is 'undefined'", () => {
test("feature-detection guard does not throw", async () => {
expect(await value(`return typeof foo === "undefined" ? "safe" : "no"`)).toBe("safe")
})
test("typeof of a declared binding is unaffected", async () => {
expect(await value(`const x = 5; return typeof x`)).toBe("number")
expect(await value(`const s = "a"; return typeof s`)).toBe("string")
})
test("referencing an undeclared identifier outside typeof still throws", async () => {
const err = await error(`return foo + 1`)
expect(err.message).toContain("foo")
})
})
describe("H1: NaN/Infinity flow as intermediates and normalize to null at the boundary", () => {
test("guards run instead of the program crashing on a transient NaN", async () => {
expect(await value(`return parseInt("abc") || 0`)).toBe(0)
expect(await value(`const x = Number("abc"); return Number.isNaN(x) ? 0 : x`)).toBe(0)
expect(await value(`const o = {}; o.count = (o.count || 0) + 1; return o.count`)).toBe(1)
// average of an empty list, guarded — the classic divide-by-zero that used to throw pre-guard
expect(await value(`const a = []; return a.length ? a.reduce((s,x)=>s+x,0)/a.length : 0`)).toBe(0)
})
test("a non-finite value becomes null when it leaves the sandbox", async () => {
expect(await value(`return 5/0`)).toBeNull()
expect(await value(`return 0/0`)).toBeNull()
expect(await value(`return Math.max()`)).toBeNull()
// nested, too — normalization walks the returned structure
expect(await value(`return { a: Number("x"), b: 2, c: [1/0] }`)).toEqual({ a: null, b: 2, c: [null] })
})
test("NaN and Infinity are usable identifiers and inspectable in-sandbox", async () => {
expect(await value(`return Number.isNaN(NaN)`)).toBe(true)
expect(await value(`return Infinity > 1e9`)).toBe(true)
expect(await value(`return Number.isFinite(1/0)`)).toBe(false)
expect(await value(`return [3,1,2].reduce((a,b)=>Math.max(a,b), -Infinity)`)).toBe(3)
// JSON.stringify inside the sandbox matches JS: non-finite serializes to null
expect(await value(`return JSON.stringify({ x: Number("z") })`)).toBe('{"x":null}')
})
test("copyOut normalizes non-finite numbers to null (the shared return + tool-arg boundary)", () => {
// Tool-call arguments funnel through copyOut too, so this one function pins both boundaries.
expect(ToolRuntime.copyOut(NaN)).toBeNull()
expect(ToolRuntime.copyOut(Infinity)).toBeNull()
expect(ToolRuntime.copyOut(-Infinity)).toBeNull()
expect(ToolRuntime.copyOut(42)).toBe(42)
expect(ToolRuntime.copyOut({ a: NaN, b: [Infinity, 1] })).toEqual({ a: null, b: [null, 1] })
})
})
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"])
})
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])
})
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(`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"])
})
test("interpreter runtime failures are caught as Error values", async () => {
expect(await value(`try { JSON.parse("nope") } catch (e) { return e instanceof Error }`)).toBe(true)
expect(await value(`try { undeclared() } catch (e) { return e instanceof Error }`)).toBe(true)
})
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(`
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])
})
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])
})
test("Promise.allSettled rejection reasons are Error values", async () => {
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"])
})
test("non-error thrown values are not instanceof Error", async () => {
expect(await value(`try { throw "raw" } catch (e) { return e instanceof Error }`)).toBe(false)
expect(await value(`try { throw { message: "shaped" } } catch (e) { return e instanceof Error }`)).toBe(false)
})
test("plain data is never instanceof Error", async () => {
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 () => {
expect(await value(`return new Error("m")`)).toEqual({ name: "Error", message: "m" })
expect(await value(`return JSON.stringify(new Error("m"))`)).toBe('{"name":"Error","message":"m"}')
expect(await value(`try { throw new Error("m") } catch (e) { return Object.keys(e) }`)).toEqual(["name", "message"])
})
test("spreading an error loses the brand, like losing the prototype in JS", async () => {
expect(await value(`const e = new Error("m"); return ({ ...e }) instanceof Error`)).toBe(false)
expect(await value(`const e = new Error("m"); return { ...e }`)).toEqual({ name: "Error", message: "m" })
})
test("typeof Error is function; an unknown instanceof right-hand side is a catchable error", async () => {
expect(await value(`return typeof Error`)).toBe("function")
expect(await value(`try { return 1 instanceof 5 } catch (e) { return "caught" }`)).toBe("caught")
const err = await error(`return 1 instanceof 5`)
expect(err.message).toContain("right-hand side of '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] })
})
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] })
})
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] })
})
test("splice rejects inserting a container into itself", async () => {
const err = await error(`const a = [1]; a.splice(0, 0, [a]); return a`)
expect(err.kind).toBe("InvalidDataValue")
expect(err.message).toContain("circular")
})
test("fill overwrites a range and returns the mutated array", async () => {
expect(await value(`const a = [1,2,3,4]; return a.fill(0, 1, 3)`)).toEqual([1, 0, 0, 4])
expect(await value(`return [1,2,3].fill("z")`)).toEqual(["z", "z", "z"])
})
test("copyWithin copies a range in place", async () => {
expect(await value(`return [1,2,3,4,5].copyWithin(0, 3)`)).toEqual([4, 5, 3, 4, 5])
})
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(`
const out = []
for (const [index, item] of ["a","b"].entries()) out.push(index + ":" + item)
return out
`)).toEqual(["0:a", "1:b"])
expect(await value(`return [...[7].entries()]`)).toEqual([[0, 7]])
})
})
describe("string methods: localeCompare, normalize, trim aliases", () => {
test("localeCompare orders strings for sorting", async () => {
expect(await value(`return ["b","a","c"].sort((x, y) => x.localeCompare(y))`)).toEqual(["a", "b", "c"])
expect(await value(`return "a".localeCompare("a")`)).toBe(0)
})
test("normalize applies unicode normalization forms", async () => {
expect(await value(`return "\\u0065\\u0301".normalize("NFC").length`)).toBe(1)
expect(await value(`return "\\u00e9".normalize("NFD").length`)).toBe(2)
expect(await value(`return "x".normalize() === "x"`)).toBe(true)
})
test("an invalid normalize form is a clear catchable error", async () => {
expect(await value(`try { "x".normalize("nope"); return "no" } catch (e) { return e.message }`)).toContain('"NFC"')
})
test("trimLeft/trimRight alias trimStart/trimEnd", async () => {
expect(await value(`return " x ".trimLeft()`)).toBe("x ")
expect(await value(`return " x ".trimRight()`)).toBe(" x")
})
})
describe("compound assignment matches its binary operator", () => {
// `x op= y` must behave exactly like `x = x op y`, sharing the binary operator's coercion
// semantics (Dates string-coerce for `+` and use their time value for arithmetic; data
// objects/arrays coerce to their JS string form).
const pair = async (compound: string, expanded: string) => {
const [a, b] = await Promise.all([value(compound), value(expanded)])
expect(a).toEqual(b)
return a
}
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`,
)
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)
})
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")
})
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")
})
test("numeric and string compound operators sweep identically to their expansions", async () => {
const cases: Array<[string, number | string]> = [
[`let x = 7; x += 3; return x`, 7 + 3],
[`let x = 7; x -= 3; return x`, 7 - 3],
[`let x = 7; x *= 3; return x`, 7 * 3],
[`let x = 7; x /= 2; return x`, 7 / 2],
[`let x = 7; x %= 3; return x`, 7 % 3],
[`let x = 7; x **= 2; return x`, 7 ** 2],
[`let x = 7; x &= 3; return x`, 7 & 3],
[`let x = 7; x |= 8; return x`, 7 | 8],
[`let x = 7; x ^= 2; return x`, 7 ^ 2],
[`let x = 7; x <<= 2; return x`, 7 << 2],
[`let x = -7; x >>= 1; return x`, -7 >> 1],
[`let x = -7; x >>>= 1; return x`, -7 >>> 1],
[`let x = "a"; x += "b"; return x`, "ab"],
]
for (const [compound, expected] of cases) {
expect(await value(compound)).toBe(expected)
expect(await value(compound.replace(/x (\S+)= /, (_, op) => `x = x ${op} `))).toBe(expected)
}
})
})
describe("H5: builtin coercion functions work as array callbacks", () => {
test("filter(Boolean) drops falsy values", async () => {
expect(await value(`return [0, 1, "", 2, null, 3].filter(Boolean)`)).toEqual([1, 2, 3])
})
test("map(String) coerces each element", async () => {
expect(await value(`return [1, 2, 3].map(String)`)).toEqual(["1", "2", "3"])
})
test("arrow callbacks still work (no regression)", async () => {
expect(await value(`return [1, 2, 3, 4].filter(x => x % 2 === 0)`)).toEqual([2, 4])
expect(await value(`return [1, 2, 3].reduce((a, b) => a + b, 0)`)).toBe(6)
})
test("a non-callable callback is still rejected", async () => {
const err = await error(`return [1,2,3].map(42)`)
expect(err.message).toContain("callback")
})
})

View file

@ -0,0 +1,426 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool, toolError, type ExecuteResult, type ExecutionLimits } from "../src/index.js"
// Wave 5 acceptance suite: first-class promise values. Un-awaited tool calls start eagerly on
// supervised fibers, `await` settles them, and Promise.all/allSettled/race/resolve/reject are
// ordinary functions over arbitrary arrays mixing promises and plain values.
type Trace = {
starts: Array<number>
active: number
maxActive: number
completed: number
interrupted: number
}
const makeTrace = (): Trace => ({ starts: [], active: 0, maxActive: 0, completed: 0, interrupted: 0 })
/** Echoes `id` after `ms` milliseconds, recording start order, live concurrency, and interruption. */
const sleepyTool = (trace: Trace) =>
Tool.make({
description: "Echo an id after a delay",
input: Schema.Struct({ id: Schema.Number, ms: Schema.optionalKey(Schema.Number) }),
output: Schema.Number,
run: ({ id, ms }) =>
Effect.gen(function*() {
trace.starts.push(id)
trace.active += 1
trace.maxActive = Math.max(trace.maxActive, trace.active)
yield* Effect.sleep(ms ?? 20)
trace.active -= 1
trace.completed += 1
return id
}).pipe(Effect.onInterrupt(() => Effect.sync(() => {
trace.active -= 1
trace.interrupted += 1
}))),
})
const failingTool = Tool.make({
description: "Always refuse",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.fail(toolError("Lookup refused")),
})
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 } : {}),
}))
}
const value = async (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}) => {
const result = await run(code, options)
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
const error = async (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}) => {
const result = await run(code, options)
if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
return result.error
}
describe("first-class promise values", () => {
test("an un-awaited tool call starts eagerly, in call order, before any await", async () => {
const trace = makeTrace()
const result = await value(
`
const a = tools.host.sleepy({ id: 1, ms: 40 })
const b = tools.host.sleepy({ id: 2, ms: 40 })
const rb = await b
const ra = await a
return [ra, rb]
`,
{ trace },
)
expect(result).toEqual([1, 2])
expect(trace.starts).toEqual([1, 2])
// Both calls overlapped even though they were awaited sequentially.
expect(trace.maxActive).toBeGreaterThan(1)
})
test("awaiting the same promise twice settles once and never re-runs the call", async () => {
const result = await run(`
const p = tools.host.sleepy({ id: 7 })
const x = await p
const y = await p
return [x, y]
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toEqual([7, 7])
expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }])
})
test("await of a non-promise value is a passthrough no-op", async () => {
expect(await value(`return await 42`)).toBe(42)
expect(await value(`const x = await "s"; return x`)).toBe("s")
expect(await value(`return await null`)).toBeNull()
expect(await value(`return (await [1, 2]).length`)).toBe(2)
})
test("returning an un-awaited tool call resolves it (async-function return semantics)", async () => {
expect(await value(`return tools.host.sleepy({ id: 9 })`)).toBe(9)
})
test("typeof a promise is 'object', and console.log renders it sensibly", async () => {
const result = await run(`
const p = Promise.resolve(1)
console.log(p)
return typeof p
`)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).toBe("object")
expect(result.logs).toStrictEqual(["[Promise (await it to get its value)]"])
})
test("an awaited failure is catchable exactly like a synchronous throw", async () => {
expect(await value(`
const p = tools.host.fail({})
try {
await p
return "no"
} catch (e) {
return e.message
}
`)).toBe("Lookup refused")
})
test("a fire-and-forget call completes before the execution ends", async () => {
const trace = makeTrace()
const result = await value(
`
tools.host.sleepy({ id: 1, ms: 30 })
return "done"
`,
{ trace },
)
expect(result).toBe("done")
expect(trace.completed).toBe(1)
expect(trace.interrupted).toBe(0)
})
test("a never-awaited failing call surfaces as an unhandled-rejection diagnostic", async () => {
const diagnostic = await error(`
tools.host.fail({})
return "done"
`)
expect(diagnostic.kind).toBe("ToolFailure")
expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited tool call")
expect(diagnostic.message).toContain("Lookup refused")
expect(diagnostic.suggestions?.join(" ")).toContain("await tools.ns.tool(...)")
})
})
describe("promises at data boundaries", () => {
test("returning an un-awaited promise inside data is a clear await-hinting diagnostic", async () => {
const diagnostic = await error(`return { result: tools.host.sleepy({ id: 1 }) }`)
expect(diagnostic.kind).toBe("InvalidDataValue")
expect(diagnostic.message).toContain("un-awaited Promise")
expect(diagnostic.message).toContain("await tools.ns.tool(...)")
})
test("passing an un-awaited promise as a tool argument is a clear diagnostic", async () => {
const diagnostic = await error(`return await tools.host.sleepy({ id: tools.host.sleepy({ id: 1 }) })`)
expect(diagnostic.kind).toBe("InvalidDataValue")
expect(diagnostic.message).toContain("un-awaited Promise")
})
test("JSON.stringify of a promise is a diagnostic, not '{}'", async () => {
const diagnostic = await error(`return JSON.stringify(Promise.resolve(1))`)
expect(diagnostic.kind).toBe("InvalidDataValue")
expect(diagnostic.message).toContain("un-awaited Promise")
})
test("operators reject promise operands", async () => {
const diagnostic = await error(`return Promise.resolve(1) + 1`)
expect(diagnostic.kind).toBe("InvalidDataValue")
})
})
describe("Promise.all over arbitrary arrays", () => {
test("mixes promises and plain values, preserving order", async () => {
expect(await value(`
return await Promise.all([tools.host.sleepy({ id: 1 }), "plain", tools.host.sleepy({ id: 2 }), 42])
`)).toEqual([1, "plain", 2, 42])
})
test("accepts arrays built beforehand, passed as identifiers, and spread elements", async () => {
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"])
})
test("runs items.map tool calls in parallel", async () => {
const trace = makeTrace()
const result = await value(
`
const ids = [1, 2, 3, 4]
return await Promise.all(ids.map((id) => tools.host.sleepy({ id, ms: 40 })))
`,
{ trace },
)
expect(result).toEqual([1, 2, 3, 4])
// maxActive counts truly-overlapping live executions, so > 1 proves real
// parallelism deterministically — no wall-clock assertion needed.
expect(trace.maxActive).toBeGreaterThan(1)
})
test("caps live tool-call concurrency at the fixed internal constant (8)", async () => {
const trace = makeTrace()
const result = await value(
`
const ids = []
for (let i = 0; i < 20; i += 1) ids.push(i)
const results = await Promise.all(ids.map((id) => tools.host.sleepy({ id, ms: 10 })))
return results.length
`,
{ trace },
)
expect(result).toBe(20)
expect(trace.maxActive).toBeGreaterThan(1)
expect(trace.maxActive).toBeLessThanOrEqual(8)
})
test("resolves the empty array", async () => {
expect(await value(`return await Promise.all([])`)).toEqual([])
})
test("rejects with the first failure, catchable in-program", async () => {
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")
})
test("a non-collection argument is a clear error", async () => {
const diagnostic = await error(`return await Promise.all(42)`)
expect(diagnostic.message).toContain("Promise.all expects an array")
})
test("exceeding maxToolCalls inside Promise.all is a ToolCallLimitExceeded diagnostic", async () => {
const diagnostic = await error(
`return await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.sleepy({ id: 2 }), tools.host.sleepy({ id: 3 })])`,
{ limits: { maxToolCalls: 2 } },
)
expect(diagnostic.kind).toBe("ToolCallLimitExceeded")
})
})
describe("Promise.allSettled", () => {
test("reports fulfilled and rejected outcomes with catch-normalized reasons", async () => {
expect(await value(`
return await Promise.allSettled([
tools.host.sleepy({ id: 5 }),
tools.host.fail({}),
"plain",
Promise.reject(new Error("boom")),
])
`)).toEqual([
{ status: "fulfilled", value: 5 },
{ status: "rejected", reason: { name: "Error", message: "Lookup refused" } },
{ status: "fulfilled", value: "plain" },
{ status: "rejected", reason: { name: "Error", message: "boom" } },
])
})
test("never rejects for program-level failures", async () => {
const result = await run(`
const settled = await Promise.allSettled([tools.host.fail({}), tools.host.fail({})])
return settled.filter((s) => s.status === "rejected").length
`)
expect(result.ok).toBe(true)
if (result.ok) expect(result.value).toBe(2)
})
})
describe("Promise.race", () => {
test("first settlement wins and losers are interrupted", async () => {
const trace = makeTrace()
const result = await value(
`
const fast = tools.host.sleepy({ id: 1, ms: 10 })
const slow = tools.host.sleepy({ id: 2, ms: 5000 })
return await Promise.race([fast, slow])
`,
{ trace },
)
expect(result).toBe(1)
expect(trace.interrupted).toBe(1)
expect(trace.completed).toBe(1)
})
test("awaiting an interrupted loser afterwards is a catchable program failure", async () => {
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])
try {
await slow
return "no"
} catch (e) {
return { winner, caught: e.message }
}
`)).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(`
try {
await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 5000 })])
return "no"
} catch (e) {
return e.message
}
`)).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(trace.interrupted).toBe(1)
})
test("an empty race is a clear error instead of hanging", async () => {
const diagnostic = await error(`return await Promise.race([])`)
expect(diagnostic.message).toContain("never settle")
})
})
describe("Promise.resolve / Promise.reject", () => {
test("resolve wraps plain values and passes promises through", async () => {
expect(await value(`return await Promise.resolve(42)`)).toBe(42)
expect(await value(`return await Promise.resolve(Promise.resolve("nested"))`)).toBe("nested")
expect(await value(`return await Promise.resolve(tools.host.sleepy({ id: 3 }))`)).toBe(3)
})
test("reject produces a promise whose await throws the reason", async () => {
expect(await value(`
try {
await Promise.reject("nope")
return "no"
} catch (e) {
return e
}
`)).toBe("nope")
})
})
describe("timeout interruption of forked calls", () => {
test("the execution timeout interrupts in-flight forked fibers", async () => {
const trace = makeTrace()
const result = await run(
`
const a = tools.host.sleepy({ id: 1, ms: 60000 })
const b = tools.host.sleepy({ id: 2, ms: 60000 })
return await a
`,
{ trace, limits: { timeoutMs: 100 } },
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.kind).toBe("TimeoutExceeded")
// Both calls started; neither escaped the timeout — the awaited one AND the abandoned one.
expect(trace.starts).toEqual([1, 2])
expect(trace.interrupted).toBe(2)
expect(trace.completed).toBe(0)
})
test("the timeout also interrupts calls inside Promise.all", async () => {
const trace = makeTrace()
const result = await run(
`return await Promise.all([tools.host.sleepy({ id: 1, ms: 60000 }), tools.host.sleepy({ id: 2, ms: 60000 })])`,
{ trace, limits: { timeoutMs: 100 } },
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.kind).toBe("TimeoutExceeded")
expect(trace.interrupted).toBe(2)
})
})
describe("unsupported promise surface", () => {
test(".then/.catch/.finally give a clear await-instead error", async () => {
for (const method of ["then", "catch", "finally"]) {
const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).${method}((x) => x)`)
expect(diagnostic.kind).toBe("UnsupportedSyntax")
expect(diagnostic.message).toContain(`Promise.prototype.${method} is not supported`)
expect(diagnostic.message).toContain("await")
}
})
test("other property reads on a promise hint at the missing await", async () => {
const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).value`)
expect(diagnostic.kind).toBe("InvalidDataValue")
expect(diagnostic.message).toContain("un-awaited Promise")
expect(diagnostic.message).toContain("await it first")
})
test("unknown Promise statics list what is available", async () => {
const diagnostic = await error(`return await Promise.any([tools.host.sleepy({ id: 1 })])`)
expect(diagnostic.message).toContain("Promise.any is not available")
expect(diagnostic.message).toContain("Promise.allSettled")
})
test("new Promise(...) points at tool calls instead", async () => {
const diagnostic = await error(`return new Promise((resolve) => resolve(1))`)
expect(diagnostic.kind).toBe("UnsupportedSyntax")
expect(diagnostic.message).toContain("new Promise(...) is not supported")
expect(diagnostic.message).toContain("already return promises")
})
})

View file

@ -0,0 +1,336 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode } from "../src/index.js"
import { Tool, inputTypeScript, jsonSchemaToTypeScript, outputTypeScript } from "../src/tool.js"
// A raw JSON Schema tool in the shape an MCP adapter produces: render-only input schema
// whose property descriptions and constraints must surface as JSDoc in pretty signatures.
const listIssues = Tool.make({
description: "List issues in a repository",
input: {
type: "object",
properties: {
owner: { type: "string", description: "Repository owner" },
after: { type: "string", description: "Cursor from the previous response's pageInfo" },
perPage: { type: "number", description: "Results per page", default: 30 },
labels: { type: "array", items: { type: "string" }, description: "Filter by labels", minItems: 1, maxItems: 10 },
state: { type: "string", enum: ["open", "closed"] },
},
required: ["owner"],
},
run: () => Effect.succeed("[]"),
})
// An Effect Schema tool whose field annotations must flow through the emitted JSON Schema.
const lookupOrder = Tool.make({
description: "Look up an order",
input: Schema.Struct({
id: Schema.String.annotate({ description: "Order identifier" }),
verbose: Schema.optionalKey(Schema.Boolean),
}),
output: Schema.Struct({
status: Schema.String.annotate({ description: "Current order status" }),
}),
run: () => Effect.succeed({ status: "open" }),
})
describe("pretty signature rendering", () => {
test("described fields get JSDoc comments; undescribed and untagged fields get none", () => {
expect(inputTypeScript(listIssues, true)).toBe(
[
"{",
" /** Repository owner */",
" owner: string",
" /** Cursor from the previous response's pageInfo */",
" after?: string",
" /**",
" * Results per page",
" * @default 30",
" */",
" perPage?: number",
" /**",
" * Filter by labels",
" * @minItems 1",
" * @maxItems 10",
" */",
" labels?: Array<string>",
' state?: "open" | "closed"',
"}",
].join("\n"),
)
})
test("compact mode output is unchanged by the pretty machinery", () => {
expect(inputTypeScript(listIssues)).toBe(
'{ owner: string; after?: string; perPage?: number; labels?: Array<string>; state?: "open" | "closed" }',
)
expect(inputTypeScript(lookupOrder)).toBe("{ id: string; verbose?: boolean }")
expect(outputTypeScript(lookupOrder)).toBe("{ status: string }")
})
test("nested objects recurse with increasing indent and their own JSDoc", () => {
const pretty = jsonSchemaToTypeScript(
{
type: "object",
properties: {
filter: {
type: "object",
description: "Search filter",
properties: { state: { type: "string", description: "Issue state" } },
},
},
},
true,
)
expect(pretty).toBe(
[
"{",
" /** Search filter */",
" filter?: {",
" /** Issue state */",
" state?: string",
" }",
"}",
].join("\n"),
)
})
test("Effect Schema annotations become JSDoc on input and output fields", () => {
expect(inputTypeScript(lookupOrder, true)).toBe(
["{", " /** Order identifier */", " id: string", " verbose?: boolean", "}"].join("\n"),
)
expect(outputTypeScript(lookupOrder, true)).toBe(
["{", " /** Current order status */", " status: string", "}"].join("\n"),
)
})
test("constraints TypeScript cannot express surface as JSDoc tags", () => {
const pretty = jsonSchemaToTypeScript(
{
type: "object",
properties: {
legacy: { type: "string", deprecated: true },
homepage: { type: "string", format: "uri" },
tags: { type: "array", items: { type: "string" }, minItems: 2, maxItems: 5, default: ["a", "b"] },
},
},
true,
)
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"),
)
})
test("skips an unserializable default rather than emitting a broken tag", () => {
const pretty = jsonSchemaToTypeScript(
{ type: "object", properties: { size: { type: "number", default: 1n } } },
true,
)
expect(pretty).toBe(["{", " size?: number", "}"].join("\n"))
})
test("neutralizes */ inside descriptions so nothing closes the comment early", () => {
const pretty = jsonSchemaToTypeScript(
{ type: "object", properties: { note: { type: "string", description: "Ends */ early" } } },
true,
)
expect(pretty).toContain(" /** Ends * / early */")
expect(pretty).not.toContain("Ends */")
})
test("multiline descriptions become *-prefixed blocks with blank edges trimmed", () => {
const pretty = jsonSchemaToTypeScript(
{
type: "object",
properties: { query: { type: "string", description: "\nFirst line\n\nSecond line\n" } },
},
true,
)
expect(pretty).toBe(
["{", " /**", " * First line", " *", " * Second line", " */", " query?: string", "}"].join("\n"),
)
})
test("stays total on cyclic $refs and pathological nesting in both modes", () => {
const cyclic = {
$ref: "#/$defs/Node",
$defs: { Node: { type: "object", properties: { child: { $ref: "#/$defs/Node" }, name: { type: "string" } } } },
} as const
expect(jsonSchemaToTypeScript(cyclic)).toBe("{ child?: Node; name?: string }")
expect(jsonSchemaToTypeScript(cyclic, true)).toContain("child?: Node")
let deep: Record<string, unknown> = { type: "string" }
for (let level = 0; level < 12; level += 1) deep = { type: "object", properties: { next: deep } }
for (const pretty of [false, true]) {
const rendered = jsonSchemaToTypeScript(deep, pretty)
expect(rendered).toContain("unknown")
expect(rendered).toContain("next?:")
}
})
})
describe("non-identifier property names render as quoted keys", () => {
// MCP-style schemas routinely carry property names that are not bare TS identifiers
// (`foo-bar`, `@type`, dotted names); the rendered signature must quote them so the
// model sees a valid TypeScript object type. Bare identifiers stay unquoted.
const rawSchema = {
type: "object",
properties: {
"foo-bar": { type: "string" },
"@type": { type: "string" },
"x.y": { type: "number", description: "Dotted name" },
"123": { type: "number" },
plain: { type: "boolean" },
},
required: ["@type"],
} as const
test("compact rendering quotes non-identifier keys and leaves identifiers bare", () => {
expect(jsonSchemaToTypeScript(rawSchema)).toBe(
'{ "123"?: number; "foo-bar"?: string; "@type": string; "x.y"?: number; plain?: boolean }',
)
})
test("pretty rendering quotes non-identifier keys and keeps their JSDoc", () => {
expect(jsonSchemaToTypeScript(rawSchema, true)).toBe(
[
"{",
' "123"?: number',
' "foo-bar"?: string',
' "@type": string',
" /** Dotted name */",
' "x.y"?: number',
" plain?: boolean",
"}",
].join("\n"),
)
})
test("JSON Schema input and output signatures of a tool both quote", () => {
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,
run: () => Effect.succeed({ "content-type": "text/plain" }),
})
expect(inputTypeScript(tool)).toContain('"foo-bar"?: string')
expect(outputTypeScript(tool)).toBe('{ "content-type": string }')
expect(outputTypeScript(tool, true)).toBe(["{", ' "content-type": string', "}"].join("\n"))
})
test("Effect Schema structs with non-identifier field names quote too", () => {
const tool = Tool.make({
description: "Schema tool with awkward field names",
input: Schema.Struct({ "foo-bar": Schema.String, plain: Schema.optionalKey(Schema.Number) }),
run: () => Effect.succeed(null),
})
expect(inputTypeScript(tool)).toBe('{ "foo-bar": string; plain?: number }')
expect(inputTypeScript(tool, true)).toBe(["{", ' "foo-bar": string', " plain?: number", "}"].join("\n"))
})
})
describe("union schemas render every alternative", () => {
test("anyOf with a number branch keeps sibling alternatives", () => {
const schema = {
anyOf: [{ type: "string" }, { type: "number" }],
} as const
expect(jsonSchemaToTypeScript(schema)).toBe("string | number")
expect(jsonSchemaToTypeScript(schema, true)).toBe("string | number")
})
test("nullable numeric unions keep null", () => {
const schema = {
oneOf: [{ type: "number" }, { type: "null" }],
} as const
expect(jsonSchemaToTypeScript(schema)).toBe("number | null")
expect(jsonSchemaToTypeScript(schema, true)).toBe("number | null")
})
test("tool input and output signatures preserve numeric unions", () => {
const tool = Tool.make({
description: "Tool with numeric unions",
input: {
type: "object",
properties: {
value: { anyOf: [{ type: "string" }, { type: "number" }] },
},
} as const,
output: { anyOf: [{ type: "number" }, { type: "boolean" }] } as const,
run: () => Effect.succeed(1),
})
expect(inputTypeScript(tool)).toBe("{ value?: string | number }")
expect(outputTypeScript(tool)).toBe("number | boolean")
})
})
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)} })`,
))
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 }
}
test("a raw JSON Schema (MCP-style) tool's result signature carries field JSDoc and tags", async () => {
const { items } = await search("list issues repository")
const item = items.find(({ path }) => path === "tools.github.list_issues")!
expect(item.signature).toBe(
[
"tools.github.list_issues(input: {",
" /** Repository owner */",
" owner: string",
" /** Cursor from the previous response's pageInfo */",
" after?: string",
" /**",
" * Results per page",
" * @default 30",
" */",
" perPage?: number",
" /**",
" * Filter by labels",
" * @minItems 1",
" * @maxItems 10",
" */",
" labels?: Array<string>",
' state?: "open" | "closed"',
"}): Promise<unknown>",
].join("\n"),
)
})
test("an annotated Effect Schema tool's result signature carries field JSDoc (exact-path lookup too)", async () => {
for (const query of ["look up order", "tools.orders.lookup"]) {
const { items } = await search(query)
const item = items.find(({ path }) => path === "tools.orders.lookup")!
expect(item.signature).toBe(
[
"tools.orders.lookup(input: {",
" /** Order identifier */",
" id: string",
" verbose?: boolean",
"}): Promise<{",
" /** Current order status */",
" status: string",
"}>",
].join("\n"),
)
}
})
test("the inline catalog line for the same tool stays single-line compact", () => {
const instructions = runtime.instructions()
expect(instructions).toContain(
' - tools.github.list_issues(input: { owner: string; after?: string; perPage?: number; labels?: Array<string>; state?: "open" | "closed" }): Promise<unknown> // List issues in a repository',
)
expect(instructions).toContain(
" - tools.orders.lookup(input: { id: string; verbose?: boolean }): Promise<{ status: string }> // Look up an order",
)
expect(instructions).not.toContain("/**")
})
})

View file

@ -0,0 +1,427 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode, Tool } from "../src/index.js"
// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS;
// intra-sandbox checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
// values, while at the host boundary (final result, tool arguments, JSON.stringify) they
// serialize exactly as JSON.stringify would: Date -> ISO string (invalid -> null),
// RegExp/Map/Set -> {}.
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const value = async (code: string) => {
const result = await run(code)
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
const error = async (code: string) => {
const result = await run(code)
if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
return result.error
}
describe("Date", () => {
test("Date.now() returns a number", async () => {
expect(await value(`return typeof Date.now()`)).toBe("number")
})
test("epoch construction and ISO rendering", async () => {
expect(await value(`return new Date(0).toISOString()`)).toBe("1970-01-01T00:00:00.000Z")
})
test("string parsing round-trips", async () => {
expect(await value(`return new Date("2024-01-02T03:04:05.000Z").getTime()`)).toBe(1704164645000)
expect(await value(`return Date.parse("2024-01-02T03:04:05.000Z")`)).toBe(1704164645000)
})
test("date arithmetic and comparison use the time value", async () => {
expect(await value(`const a = new Date(1000); const b = new Date(3000); return b - a`)).toBe(2000)
expect(await value(`const a = new Date(1000); const b = new Date(3000); return a < b`)).toBe(true)
expect(await value(`return +new Date(42)`)).toBe(42)
})
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])
})
test("invalid dates yield NaN times, guardable in-sandbox", async () => {
expect(await value(`return Number.isNaN(new Date("garbage").getTime())`)).toBe(true)
expect(await value(`return new Date("garbage").toJSON()`)).toBeNull()
})
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")
})
test("template interpolation renders the ISO form", async () => {
expect(await value("return `at ${new Date(0)}`")).toBe("at 1970-01-01T00:00:00.000Z")
})
test("dates serialize to ISO strings at the boundary, direct and nested", async () => {
expect(await value(`return new Date(0)`)).toBe("1970-01-01T00:00:00.000Z")
expect(await value(`return { when: new Date(0), tags: [new Date(1000)] }`)).toEqual({
when: "1970-01-01T00:00:00.000Z",
tags: ["1970-01-01T00:00:01.000Z"],
})
expect(await value(`return JSON.stringify({ d: new Date(0) })`)).toBe('{"d":"1970-01-01T00:00:00.000Z"}')
})
test("coercions: Number is the time, String is ISO, Boolean is true", async () => {
expect(await value(`return Number(new Date(5))`)).toBe(5)
expect(await value(`return String(new Date(0))`)).toBe("1970-01-01T00:00:00.000Z")
expect(await value(`return Boolean(new Date(0))`)).toBe(true)
})
test("sorting dates with a numeric comparator", async () => {
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])
})
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])
})
test("typeof and unknown properties are forgiving", async () => {
expect(await value(`return typeof new Date(0)`)).toBe("object")
expect(await value(`return new Date(0).nope === undefined`)).toBe(true)
})
})
describe("RegExp", () => {
test("literal test", async () => {
expect(await value(`return /ab+c/.test("xabbbc")`)).toBe(true)
expect(await value(`return /ab+c/.test("nope")`)).toBe(false)
})
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(`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")
})
test("global exec advances lastIndex across calls", async () => {
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"])
})
test("string match: non-global carries index, global lists all matches", async () => {
expect(await value(`const m = "a1b22".match(/\\d+/); return [m[0], m.index]`)).toEqual(["1", 1])
expect(await value(`return "a1b22".match(/\\d+/g)`)).toEqual(["1", "22"])
expect(await value(`return "abc".match(/\\d/)`)).toBeNull()
})
test("matchAll materializes match arrays with captures", async () => {
expect(await value(`return "a1b22".matchAll(/(\\d+)/g).map((m) => m[1])`)).toEqual(["1", "22"])
})
test("replace and replaceAll with patterns and $1 substitution", async () => {
expect(await value(`return "a1b2".replace(/\\d/, "#")`)).toBe("a#b2")
expect(await value(`return "a1b2".replace(/\\d/g, "#")`)).toBe("a#b#")
expect(await value(`return "a1b2".replaceAll(/\\d/g, "#")`)).toBe("a#b#")
expect(await value(`return "hi bob".replace(/b(o)b/, "[$1]")`)).toBe("hi [o]")
})
test("replaceAll without the g flag is a catchable error", async () => {
expect(await value(`try { "a".replaceAll(/a/, "b"); return "no" } catch { return "caught" }`)).toBe("caught")
})
test("split and search accept patterns", async () => {
expect(await value(`return "a1b22c".split(/\\d+/)`)).toEqual(["a", "b", "c"])
expect(await value(`return "ab42".search(/\\d/)`)).toBe(2)
expect(await value(`return "ab".search(/\\d/)`)).toBe(-1)
})
test("new RegExp constructs from strings; invalid patterns are catchable", async () => {
expect(await value(`return new RegExp("a+", "i").test("AAA")`)).toBe(true)
expect(await value(`try { new RegExp("("); return "no" } catch { return "caught" }`)).toBe("caught")
expect(await value(`return [/a/ instanceof RegExp, /a/.source]`)).toEqual([true, "a"])
})
test("invalid patterns fail with actionable messages", async () => {
const fromString = await error(`return "abc".match("(")`)
expect(fromString.message).toContain('String.match received the string "("')
expect(fromString.message).toContain("escape them with a backslash")
const fromConstructor = await error(`return new RegExp("(")`)
expect(fromConstructor.message).toContain('new RegExp(...) received "("')
expect(fromConstructor.message).toContain("escape them with a backslash")
const fromFlags = await error(`return new RegExp("a", "xz")`)
expect(fromFlags.message).toContain('invalid flags "xz"')
expect(fromFlags.message).toContain("Valid flags are")
})
test("missing g-flag errors say how to fix the call", async () => {
expect((await error(`return "aa".replaceAll(/a/, "b")`)).message).toContain("write /a/g, or use String.replace")
expect((await error(`return "aa".matchAll(/a/)`)).message).toContain("write /a/g, or use String.match")
})
test("a non-pattern argument names the expected shapes", async () => {
const err = await error(`return "abc".match(42)`)
expect(err.message).toContain("expects a regular expression")
expect(err.message).toContain("not number")
})
test("source and flags properties read through", async () => {
expect(await value(`const r = /ab/gi; return { source: r.source, flags: r.flags, global: r.global }`)).toEqual({
source: "ab",
flags: "gi",
global: true,
})
})
test("regexes serialize to {} at the boundary, like JSON", async () => {
expect(await value(`return /a/`)).toEqual({})
expect(await value(`return JSON.stringify({ r: /a/g })`)).toBe('{"r":{}}')
})
test("template interpolation renders the literal form", async () => {
expect(await value("return `${/ab/g}`")).toBe("/ab/g")
})
})
describe("Map", () => {
test("get/set/has/size with chaining", async () => {
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 })
})
test("object keys use identity", async () => {
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])
})
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 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(`
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]] })
})
test("Object.fromEntries(map) and Array.from(map)", async () => {
expect(await value(`return Object.fromEntries(new Map([["a", 1], ["b", 2]]))`)).toEqual({ a: 1, b: 2 })
expect(await value(`return Array.from(new Map([["a", 1]]))`)).toEqual([["a", 1]])
})
test("for...of iterates [key, value] pairs with destructuring", async () => {
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")
})
test("spread produces entry pairs", async () => {
expect(await value(`return [...new Map([["a", 1]])]`)).toEqual([["a", 1]])
})
test("forEach passes (value, key)", async () => {
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"])
})
test("delete and clear", async () => {
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])
})
test("counting idiom: grouped tallies", async () => {
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 })
})
test("maps serialize to {} at the boundary, like JSON", async () => {
expect(await value(`return new Map([["a", 1]])`)).toEqual({})
expect(await value(`return JSON.stringify(new Map([["a", 1]]))`)).toBe("{}")
})
test("console.log renders map contents for debugging", async () => {
const result = await run(`console.log(new Map([["a", 1]])); return null`)
expect(result.ok).toBe(true)
expect(result.logs?.[0]).toBe(`Map(1) [["a",1]]`)
})
})
describe("Set", () => {
test("add/has/delete/size with chaining", async () => {
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])
})
test("dedupe idiom: [...new Set(items)]", async () => {
expect(await value(`return [...new Set([1, 2, 2, 3, 1])]`)).toEqual([1, 2, 3])
})
test("construction from strings and other Sets", async () => {
expect(await value(`return [...new Set("aba")]`)).toEqual(["a", "b"])
expect(await value(`return Array.from(new Set(new Set([1, 2])))`)).toEqual([1, 2])
})
test("SameValueZero: NaN is findable", async () => {
expect(await value(`const s = new Set([NaN]); return s.has(NaN)`)).toBe(true)
})
test("for...of iterates values", async () => {
expect(await value(`
let total = 0
for (const n of new Set([1, 2, 3])) total += n
return total
`)).toBe(6)
})
test("sets serialize to {} at the boundary, like JSON", async () => {
expect(await value(`return { s: new Set([1]) }`)).toEqual({ s: {} })
})
})
describe("stdlib integration", () => {
test("typeof reports constructors as functions and never throws", async () => {
expect(await value(`return typeof Map`)).toBe("function")
expect(await value(`return typeof ((x) => x)`)).toBe("function")
expect(await value(`return typeof Math`)).toBe("object")
expect(await value(`return typeof tools`)).toBe("object")
})
test("negation works on any value", async () => {
expect(await value(`return !new Map()`)).toBe(false)
expect(await value(`const fn = () => 1; return !fn`)).toBe(false)
})
test("object spread of sandbox values is a no-op, like JS", async () => {
expect(await value(`return { ...new Map([["a", 1]]), kept: true }`)).toEqual({ kept: true })
})
test("dates inside Map values survive in-sandbox reads", async () => {
expect(await value(`
const m = new Map([["start", new Date(1000)]])
return m.get("start").getTime()
`)).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 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)
})
test("realistic pipeline: parse, extract with regex, dedupe, count by day", async () => {
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()
const byDay = new Map()
for (const row of rows) {
for (const m of row.tag.matchAll(/[a-z]+/g)) tags.add(m[0])
const day = new Date(row.at).toISOString().slice(0, 10)
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 } })
})
})
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")
})
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)
})
test("object and array spread keep sandbox values usable", async () => {
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])
expect(await value(`const list = [new Date(1000)]; const copy = [...list]; return copy[0].getTime()`)).toBe(1000)
})
test("Array.from over arrays keeps nested sandbox values usable", async () => {
expect(await value(`return Array.from([new Date(5)])[0].getTime()`)).toBe(5)
})
test("regexes stay callable through Object.values", async () => {
expect(await value(`return Object.values({ r: /ab+/ })[0].test("abb")`)).toBe(true)
})
test("Object.* helpers see sandbox values as empty objects, never internals", async () => {
expect(await value(`return Object.keys(new Map([["a", 1]]))`)).toEqual([])
expect(await value(`return Object.values(new Date(0))`)).toEqual([])
expect(await value(`return Object.entries(new Set([1]))`)).toEqual([])
expect(await value(`return Object.assign({}, new Map([["a", 1]]))`)).toEqual({})
expect(await value(`return Object.hasOwn(new Date(0), "time")`)).toBe(false)
})
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 JSON.stringify({ d: new Date(0) })`)).toBe('{"d":"1970-01-01T00:00:00.000Z"}')
const observed: Array<unknown> = []
const capture = Tool.make({
description: "Capture the exact input the host receives",
input: { type: "object" },
run: (input) =>
Effect.sync(() => {
observed.push(input)
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]]) })`,
}))
expect(result.ok).toBe(true)
expect(observed).toStrictEqual([{ when: "1970-01-01T00:00:00.000Z", tags: {} }])
})
})

View file

@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"noUncheckedIndexedAccess": false
}
}

View file

@ -84,6 +84,7 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/protocol": "workspace:*",

View file

@ -43,6 +43,7 @@ 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"),

View file

@ -159,6 +159,12 @@ export interface Interface {
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 prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
readonly resourceTemplates: (
@ -680,6 +686,18 @@ const layer = Layer.effect(
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
}
}
return result
})
function collectFromConnected<T extends { name: string }>(
s: State,
listFn: (c: Client, timeout?: number) => Promise<T[]>,
@ -982,6 +1000,7 @@ const layer = Layer.effect(
clients,
instructions,
tools,
defs,
prompts,
resources,
resourceTemplates,

View file

@ -0,0 +1,65 @@
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"

View file

@ -213,6 +213,18 @@ 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)))
}
export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] })
export * as Permission from "."

View file

@ -206,11 +206,11 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre
})
function resolveTools(input: Pick<PrepareInput, "tools" | "agent" | "permission" | "user">) {
const disabled = Permission.disabled(
Object.keys(input.tools),
const visible = Permission.visibleTools(
input.tools,
Permission.merge(input.agent.permission, input.permission ?? []),
)
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
return Record.filter(visible, (_, k) => input.user.tools?.[k] !== false)
}
export function hasToolCalls(messages: ModelMessage[]): boolean {

View file

@ -1237,6 +1237,8 @@ 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") {

View file

@ -3,6 +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 { Permission } from "@/permission"
import { Tool } from "@/tool/tool"
import { ToolJsonSchema } from "@/tool/json-schema"
@ -21,6 +22,7 @@ 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",
@ -52,6 +54,7 @@ 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,
@ -86,11 +89,20 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
.pipe(Effect.orDie),
})
for (const item of yield* registry.tools({
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({
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,
@ -381,7 +393,9 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
})
}
for (const [key, item] of Object.entries(yield* mcp.tools())) {
if (codeMode) return tools
for (const [key, item] of Object.entries(mcpTools)) {
const execute = item.execute
if (!execute) continue
@ -392,29 +406,19 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
run.promise(
Effect.gen(function* () {
const ctx = context(args, opts)
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,
)
// 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,
})
const textParts: string[] = []
const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = []

View file

@ -0,0 +1,428 @@
import * as Tool from "./tool"
import type { Tool as AITool } from "ai"
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Schema } from "effect"
import {
CodeMode,
Tool as SandboxTool,
toolError,
type ExecuteResult,
type JsonSchema,
type ToolDefinition,
} 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"
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.",
].join("\n")
export const Parameters = Schema.Struct({
code: Schema.String.annotate({
description: [
"JavaScript source to execute.",
"Inside CodeMode, `tools` contains only the MCP/CodeMode tools listed in this execute tool's description; top-level opencode tools like bash, read, or lsp are not available unless listed there.",
"Call available tools using the exact signatures shown in this execute tool's description, compose the results, and `return` the final value.",
].join(" "),
}),
})
/** 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 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]
/** 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 = {
path: string
key: string
server: string
local: string
description: string
tool: AITool
inputSchema: JsonSchema
outputSchema?: JsonSchema
}
/** 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[]> {
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 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)
return CodeMode.make({
tools: toolTree(catalog, () => () => 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)
return segment.length > 0 ? segment : undefined
}
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 : []
const text: string[] = []
let files = 0
let images = 0
const push = (attachment: Attachment) => {
files += 1
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>
switch (block.type) {
case "text":
if (typeof block.text === "string") 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) })
}
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)
}
}
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),
})
}
break
}
}
if (record.structuredContent !== undefined && record.structuredContent !== null) return record.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)
}
}
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,
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))
}),
)
export const CodeModeTool = Tool.define(
CODE_MODE_TOOL,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const agents = yield* Agent.Service
const sessions = yield* Session.Service
const plugin = yield* Plugin.Service
const init: Tool.DefWithoutID<typeof Parameters, Metadata> = {
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,
metadata: { toolCalls: [], error: true },
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 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 })) } })
// 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)
}),
)
const runtime = CodeMode.make({
tools: toolTree(catalog, callTool),
onToolCallStart: ({ index, name, input }) =>
Effect.suspend(() => {
const shown = displayInput(input)
calls[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
return publish()
}),
onToolCallEnd: ({ index, outcome }) =>
Effect.suspend(() => {
const current = calls[index]
if (current) calls[index] = { ...current, status: outcome === "success" ? "completed" : "error" }
return publish()
}),
})
// 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 result = yield* Effect.raceFirst(runtime.execute(params.code), cancelled)
const logs = result.logs ?? []
const attached = attachments.length > 0 ? { attachments } : {}
if (result.ok) {
return {
title: CODE_MODE_TOOL,
metadata: { toolCalls: calls },
output: withLogs(formatValue(result.value), logs),
...attached,
} satisfies Tool.ExecuteResult<Metadata>
}
// 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))
return {
title: CODE_MODE_TOOL,
metadata: { toolCalls: calls, error: true },
output: withLogs([result.error.message, ...hints].join("\n"), logs),
...attached,
} satisfies Tool.ExecuteResult<Metadata>
}),
}
return init
}),
)

View file

@ -26,6 +26,9 @@ 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"
@ -51,6 +54,7 @@ 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
@ -74,6 +78,7 @@ export interface Interface {
providerID: ProviderV2.ID
modelID: ModelV2.ID
agent: Agent.Info
permission?: PermissionV1.Ruleset
}) => Effect.Effect<Tool.Def[]>
}
@ -87,6 +92,7 @@ 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
@ -104,6 +110,7 @@ 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>(
@ -211,6 +218,7 @@ const layer = Layer.effect(
question: Tool.init(question),
lsp: Tool.init(lsptool),
plan: Tool.init(plan),
codemode: Tool.init(codemode),
})
return {
@ -232,6 +240,7 @@ 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,
@ -263,11 +272,25 @@ 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")
@ -292,7 +315,11 @@ const layer = Layer.effect(
: undefined
return {
id: tool.id,
description: [output.description, tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined]
description: [
output.description,
tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,
tool.id === CodeModeTool.id ? yield* describeCodeMode(input.agent, input.permission) : undefined,
]
.filter(Boolean)
.join("\n"),
parameters: output.parameters,
@ -314,6 +341,7 @@ const layer = Layer.effect(
}),
)
function isZodType(value: unknown): value is z.ZodType {
return typeof value === "object" && value !== null && "_zod" in value
}
@ -406,6 +434,7 @@ export const node = LayerNode.make({
LSP.node,
Instruction.node,
FSUtil.node,
MCP.node,
EventV2Bridge.node,
httpClient,
CrossSpawnSpawner.node,

View file

@ -117,6 +117,7 @@ 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({}),

View file

@ -39,6 +39,7 @@ 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({}),

View file

@ -0,0 +1,164 @@
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" }] })
}),
)
})

View file

@ -0,0 +1,354 @@
import { beforeAll, describe, expect, test } from "bun:test"
import { CodeModeTool, catalogInstructions } from "@/tool/code-mode"
import { McpCatalog } from "@/mcp/catalog"
import { Agent } from "@/agent/agent"
import { MCP } from "@/mcp"
import { Plugin } from "@/plugin"
import { Session } from "@/session/session"
import { Tool } from "@/tool/tool"
import * as Truncate from "@/tool/truncate"
import { MessageID, SessionID } from "@/session/schema"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
import type { Client } from "@modelcontextprotocol/sdk/client/index.js"
import {
CallToolRequestSchema,
LATEST_PROTOCOL_VERSION,
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 SERVER = "fixtures"
const ctx: Tool.Context = {
sessionID: SessionID.make("ses_code-mode-int"),
messageID: MessageID.make("msg_code-mode-int"),
agent: "build",
abort: new AbortController().signal,
callID: "call_code_mode_int",
messages: [],
metadata: () => Effect.void,
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.
*/
class RawJsonRpcClient {
private nextId = 1
private pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void }>()
constructor(private transport: InMemoryTransport) {}
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
const entry = this.pending.get(msg.id)
if (!entry) return
this.pending.delete(msg.id)
if (msg.error) entry.reject(new Error(msg.error.message))
else entry.resolve(msg.result)
}
await this.transport.start()
await this.request("initialize", {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "test-client", version: "1.0.0" },
})
await this.transport.send({ jsonrpc: "2.0", method: "notifications/initialized" })
}
private request(method: string, params: unknown): Promise<any> {
const id = this.nextId++
const result = new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }))
void this.transport.send({ jsonrpc: "2.0", id, method, params } as never)
return result
}
listTools() {
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",
description: "Greet someone and return the greeting as text",
inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] },
},
{
name: "add",
description: "Add two numbers and return the structured sum",
inputSchema: { type: "object", properties: { a: { type: "number" }, b: { type: "number" } }, required: ["a", "b"] },
outputSchema: { type: "object", properties: { sum: { type: "number" } }, required: ["sum"] },
},
{
name: "screenshot",
description: "Capture a screenshot and return it as an image",
inputSchema: { type: "object", properties: {} },
},
{
name: "boom",
description: "A tool that always fails",
inputSchema: { type: "object", properties: {} },
},
] as MCPToolDef[]
function handleCall(name: string, args: Record<string, unknown>) {
switch (name) {
case "get_text":
return { content: [{ type: "text", text: `hello ${args.name}` }] }
case "add": {
const sum = (args.a as number) + (args.b as number)
return { content: [{ type: "text", text: String(sum) }], structuredContent: { sum } }
}
case "screenshot":
return { content: [{ type: "image", data: PNG, mimeType: "image/png" }] }
case "boom":
return { content: [{ type: "text", text: "kaboom" }], isError: true }
default:
return { content: [{ type: "text", text: `unknown tool ${name}` }], isError: true }
}
}
let tool: Awaited<ReturnType<typeof buildTool>>["tool"]
let description: string
async function buildTool() {
const server = new Server({ name: SERVER, version: "1.0.0" }, { capabilities: { tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS }))
server.setRequestHandler(CallToolRequestSchema, async (req) =>
handleCall(req.params.name, (req.params.arguments ?? {}) as Record<string, unknown>),
)
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
await server.connect(serverTransport)
const client = new RawJsonRpcClient(clientTransport)
await client.connect()
const listed = (await client.listTools()).tools as MCPToolDef[]
const mcpTools: Record<string, AITool> = {}
const mcpDefs: Record<string, MCPToolDef> = {}
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)
}
// 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"]),
}),
Layer.mock(Truncate.Service, {
output: (text: string) => Effect.succeed({ content: text, truncated: false as const }),
}),
Layer.mock(Agent.Service, { get: () => Effect.succeed({ name: "build", permission: [] } as any) }),
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]),
}
}
const run = (code: string) => Effect.runPromise(tool.execute({ code }, ctx))
beforeAll(async () => {
const built = await buildTool()
tool = built.tool
description = built.description
})
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.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")
})
test("calls a text tool and receives its text as the native result", async () => {
const out = await run("const r = await tools.fixtures.get_text({ name: 'world' }); return r")
expect(out.output).toBe("hello world")
expect(out.metadata.toolCalls).toEqual([
{ tool: "fixtures.get_text", status: "completed", input: { name: "world" } },
])
expect(out.attachments).toBeUndefined()
})
test("exposes structured data natively from a tool with an outputSchema", async () => {
const out = await run("const r = await tools.fixtures.add({ a: 2, b: 3 }); return r.sum")
expect(out.output).toBe("5")
})
test("composes multiple structured calls and returns a plain object", async () => {
const out = await run(`
const first = await tools.fixtures.add({ a: 1, b: 2 })
const second = await tools.fixtures.add({ a: first.sum, b: 10 })
return { total: second.sum }
`)
expect(JSON.parse(out.output)).toEqual({ total: 13 })
expect(out.metadata.toolCalls).toEqual([
{ tool: "fixtures.add", status: "completed", input: { a: 1, b: 2 } },
{ tool: "fixtures.add", status: "completed", input: { a: 3, b: 10 } },
])
})
test("an image result becomes an execute attachment and a marker in the sandbox", async () => {
const out = await run("return await tools.fixtures.screenshot({})")
expect(out.output).toBe("[1 image attached to the result]")
expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: `data:image/png;base64,${PNG}` }])
})
test("image bytes never enter the sandbox or the model-facing output", async () => {
const out = await run(`
const shot = await tools.fixtures.screenshot({})
return { sawMarker: typeof shot === 'string' && shot.includes('attached'), value: shot }
`)
expect(JSON.parse(out.output)).toEqual({
sawMarker: true,
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)
})
test("attachments accumulate even when the program returns something else", async () => {
const out = await run("await tools.fixtures.screenshot({}); return 'captured'")
expect(out.output).toBe("captured")
expect(out.attachments).toHaveLength(1)
})
test("runs calls in parallel and accumulates every attachment", async () => {
const out = await run(`
const both = await Promise.all([tools.fixtures.screenshot({}), tools.fixtures.screenshot({})])
return 'two shots: ' + both.length
`)
expect(out.output).toBe("two shots: 2")
expect(out.attachments).toHaveLength(2)
expect(out.metadata.toolCalls.map((c) => c.tool)).toEqual(["fixtures.screenshot", "fixtures.screenshot"])
})
test("propagates an MCP isError into the program as a catchable error", async () => {
const out = await run("try { await tools.fixtures.boom({}) } catch (e) { return 'caught: ' + e.message }")
expect(out.output).toBe("caught: kaboom")
})
test("an uncaught MCP error surfaces as a failed execution", async () => {
const out = await run("await tools.fixtures.boom({}); return 'unreachable'")
expect(out.metadata.error).toBe(true)
expect(out.output).toContain("kaboom")
})
test("console output is captured and appended as a Logs section after the result", async () => {
const out = await run(`
console.log("looking up", { name: "world" })
const r = await tools.fixtures.get_text({ name: "world" })
console.warn("got", r)
return r
`)
expect(out.output).toBe('hello world\n\nLogs:\nlooking up {"name":"world"}\n[warn] got hello world')
expect(out.metadata.error).toBeUndefined()
})
test("console output is preserved on the error path", async () => {
const out = await run(`
console.log("before the throw")
await tools.fixtures.boom({})
return "unreachable"
`)
expect(out.metadata.error).toBe(true)
expect(out.output).toContain("kaboom")
expect(out.output).toContain("Logs:\nbefore the throw")
})
test("a program that logs nothing gets no Logs section", async () => {
const out = await run("return 'quiet'")
expect(out.output).toBe("quiet")
expect(out.output).not.toContain("Logs:")
})
test("console does not consume the tool-call metadata (logging is not a tool call)", async () => {
const out = await run("console.log('hi'); console.error('bye'); return 'ok'")
expect(out.output).toBe("ok\n\nLogs:\nhi\n[error] bye")
expect(out.metadata.toolCalls).toEqual([])
})
test("asks permission for each MCP call, keyed by the flat catalog name", async () => {
const asked: string[] = []
const permCtx: Tool.Context = { ...ctx, ask: (req: any) => Effect.sync(() => void asked.push(req.permission)) }
await Effect.runPromise(
tool.execute(
{
code: `
await tools.fixtures.add({ a: 1, b: 1 })
await tools.fixtures.get_text({ name: 'x' })
return 'done'
`,
},
permCtx,
),
)
expect(asked).toEqual(["fixtures_add", "fixtures_get_text"])
})
test("streams running/completed metadata for child calls over a real transport", async () => {
const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record<string, unknown> }[] }> = []
const recordingCtx: Tool.Context = {
...ctx,
metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)),
}
await Effect.runPromise(
tool.execute({ code: "await tools.fixtures.add({ a: 1, b: 2 }); return 'done'" }, recordingCtx),
)
expect(snapshots).toContainEqual({
toolCalls: [{ tool: "fixtures.add", status: "running", input: { a: 1, b: 2 } }],
})
expect(snapshots).toContainEqual({
toolCalls: [{ tool: "fixtures.add", status: "completed", input: { a: 1, b: 2 } }],
})
})
})

View file

@ -0,0 +1,861 @@
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 type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Agent } from "@/agent/agent"
import { MCP } from "@/mcp"
import { Permission } from "@/permission"
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 = {
sessionID: SessionID.make("ses_code-mode"),
messageID: MessageID.make("msg_code-mode"),
agent: "build",
abort: new AbortController().signal,
callID: "call_code_mode",
messages: [],
metadata: () => Effect.void,
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 ?? {}),
}
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>
servers: string[]
permission?: PermissionV1.Rule[]
trigger?: Plugin.Interface["trigger"]
}) {
return Layer.mergeAll(
Layer.mock(Plugin.Service, {
trigger: input.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 }),
}),
Layer.mock(Agent.Service, {
get: () => Effect.succeed({ name: "build", permission: input.permission ?? [] } as any),
}),
Layer.mock(Session.Service, {
get: () => Effect.succeed({ permission: [] } as any),
}),
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[]) {
return servers ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))]
}
function build(
mcpTools: Record<string, AITool>,
defs: Record<string, MCPToolDef> = {},
servers?: string[],
permission?: PermissionV1.Rule[],
trigger?: Plugin.Interface["trigger"],
) {
const names = serverNames(mcpTools, servers)
return Effect.runPromise(
CodeModeTool.pipe(
Effect.flatMap(Tool.init),
Effect.provide(harness({ mcpTools, defs, 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))
}
describe("code mode execute", () => {
test("defines execute input with an Effect schema", async () => {
const decode = Schema.decodeUnknownEffect(Parameters)
await expect(Effect.runPromise(decode({ code: "return 1" }))).resolves.toEqual({ code: "return 1" })
await expect(Effect.runPromise(decode({}))).rejects.toThrow()
})
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",
})
})
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",
})
})
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("the static base description carries no catalog; the registry appends it", async () => {
const tool = await build({ github_list_issues: mcpTool("list_issues", () => "") })
expect(tool.id).toBe(CODE_MODE_TOOL)
expect(tool.description).toContain("confined runtime")
expect(tool.description).not.toContain("Available tools")
expect(tool.description).not.toContain("list_issues")
})
test("small catalogs inline every full signature in the appended catalog", () => {
const description = describeFor({
github_create_issue: mcpTool("create_issue", () => "", {
type: "object",
properties: { title: { type: "string" }, body: { type: "string" } },
required: ["title"],
}),
github_list_issues: mcpTool("list_issues", () => ""),
linear_search: mcpTool("search", () => ""),
})
expect(description).toContain("Available tools (COMPLETE list")
expect(description).toContain("- github (2 tools)")
expect(description).toContain("- linear (1 tool)")
expect(description).toContain(
"tools.github.create_issue(input: { title: string; body?: string }): Promise<unknown>",
)
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("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: {
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 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(
{
name: `op_${i}`,
description: `${filler}${i}`,
inputSchema: { type: "object", properties: { value: { type: "string" }, count: { type: "number" } } },
} as any,
client as any,
)
}
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"])
// 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).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 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")
expect(description).not.toContain("/**")
expect(out.metadata.toolCalls).toEqual([
{ tool: "$codemode.search", status: "completed", input: { query: "only tool", limit: 3 } },
])
})
test("runs plain JavaScript and returns the value as text", async () => {
const tool = await build({})
const output = await Effect.runPromise(tool.execute({ code: "return 1 + 2" }, ctx))
expect(output.output).toBe("3")
expect(output.metadata.toolCalls).toEqual([])
})
test("Object.keys(tools) enumerates the MCP server namespaces", async () => {
const tool = await build({
github_list_issues: mcpTool("list_issues", () => ""),
linear_search: mcpTool("search", () => ""),
})
const output = await Effect.runPromise(
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 })
})
test("calls a namespaced MCP tool and flows its text result back into the program", async () => {
const seen: Record<string, unknown>[] = []
const tool = await build({
greeter_hello: mcpTool("hello", (args) => {
seen.push(args)
return { content: [{ type: "text", text: `hello ${args.name}` }] }
}),
})
const output = await Effect.runPromise(
tool.execute({ code: "const r = await tools.greeter.hello({ name: 'world' }); return r.toUpperCase()" }, ctx),
)
expect(seen).toEqual([{ name: "world" }])
expect(output.output).toBe("HELLO WORLD")
expect(output.metadata.toolCalls).toEqual([
{ tool: "greeter.hello", status: "completed", input: { name: "world" } },
])
})
test("exposes structured content as native data and composes multiple calls", async () => {
const tool = await build({
math_add: mcpTool("add", (args) => ({
content: [],
structuredContent: { sum: (args.a as number) + (args.b as number) },
})),
})
const output = await Effect.runPromise(
tool.execute(
{
code: `
const first = await tools.math.add({ a: 1, b: 2 })
const second = await tools.math.add({ a: first.sum, b: 10 })
return { total: second.sum }
`,
},
ctx,
),
)
expect(JSON.parse(output.output)).toEqual({ total: 13 })
expect(output.metadata.toolCalls).toEqual([
{ tool: "math.add", status: "completed", input: { a: 1, b: 2 } },
{ tool: "math.add", status: "completed", input: { a: 3, b: 10 } },
])
})
test("runs tool calls in parallel with Promise.all", async () => {
const tool = await build({
echo_one: mcpTool("one", () => ({ content: [{ type: "text", text: "1" }] })),
echo_two: mcpTool("two", () => ({ content: [{ type: "text", text: "2" }] })),
})
const output = await Effect.runPromise(
tool.execute(
{ code: "const [a, b] = await Promise.all([tools.echo.one({}), tools.echo.two({})]); return a + b" },
ctx,
),
)
expect(output.output).toBe("12")
expect(output.metadata.toolCalls.map((c) => c.tool).sort()).toEqual(["echo.one", "echo.two"])
expect(output.metadata.toolCalls.every((c) => c.status === "completed")).toBe(true)
})
test("returns a readable error when the program throws", async () => {
const tool = await build({})
const output = await Effect.runPromise(tool.execute({ code: "throw new Error('boom')" }, ctx))
expect(output.output).toBe("Uncaught: boom")
expect(output.metadata.error).toBe(true)
})
test("reports an unknown tool as a failed execution", async () => {
const tool = await build({ known_tool: mcpTool("tool", () => "ok") })
const output = await Effect.runPromise(tool.execute({ code: "return await tools.known.missing({})" }, ctx))
expect(output.metadata.error).toBe(true)
expect(output.output).toContain("Unknown tool 'known.missing'")
})
test("propagates an MCP tool error into the program as a catchable failure", async () => {
const tool = await build({
bad_tool: mcpTool("tool", () => ({ isError: true, content: [{ type: "text", text: "server exploded" }] })),
})
const output = await Effect.runPromise(
tool.execute({ code: "try { await tools.bad.tool({}) } catch (e) { return 'caught: ' + e.message }" }, ctx),
)
expect(output.output).toBe("caught: server exploded")
})
test("asks permission before each child tool call", async () => {
const asked: unknown[] = []
const permissionCtx: Tool.Context = { ...ctx, ask: (req) => Effect.sync(() => void asked.push(req)) }
const ok = () => ({ content: [{ type: "text", text: "ok" }] })
const tool = await build({ a_tool: mcpTool("a", ok), b_tool: mcpTool("b", ok) })
await Effect.runPromise(
tool.execute({ code: "await tools.a.tool({}); await tools.b.tool({}); return 'done'" }, permissionCtx),
)
expect(asked.map((req: any) => req.permission)).toEqual(["a_tool", "b_tool"])
})
test("a denied permission fails the child call with a catchable message, not the whole execute", async () => {
const denyCtx: Tool.Context = { ...ctx, ask: () => Effect.die(new Error("permission denied by user")) }
const called: string[] = []
const tool = await build({
a_tool: mcpTool("a", () => {
called.push("a")
return { content: [{ type: "text", text: "ok" }] }
}),
})
const output = await Effect.runPromise(
tool.execute({ code: "try { await tools.a.tool({}) } catch (e) { return 'denied: ' + e.message }" }, denyCtx),
)
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" }])
})
test("child calls fire plugin tool.execute hooks with the MCP key and synthetic parent/N call ids", async () => {
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 tool = await build(
{
a_tool: mcpTool("a", () => ({ content: [{ type: "text", text: "one" }] })),
b_tool: mcpTool("b", () => ({ content: [{ type: "text", text: "two" }] })),
},
{},
undefined,
undefined,
trigger,
)
const out = await Effect.runPromise(
tool.execute({ code: "await tools.a.tool({ x: 1 }); await tools.b.tool({}); return 'done'" }, ctx),
)
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"],
["tool.execute.before", "b_tool", "call_code_mode/2"],
["tool.execute.after", "b_tool", "call_code_mode/2"],
])
const [before, after] = events
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" }] })
})
test("a failing before hook fails only that child call as a catchable in-program error", async () => {
const trigger = ((name: unknown, input: any, output: unknown) => {
if (name === "tool.execute.before" && input.tool === "a_tool") return Effect.die(new Error("hook exploded"))
return Effect.succeed(output)
}) as Plugin.Interface["trigger"]
const called: string[] = []
const record = (name: string) => () => {
called.push(name)
return { content: [{ type: "text", text: "ok" }] }
}
const tool = await build(
{ a_tool: mcpTool("a", record("a")), b_tool: mcpTool("b", record("b")) },
{},
undefined,
undefined,
trigger,
)
const out = await Effect.runPromise(
tool.execute(
{
code: `
let caught
try { await tools.a.tool({}) } catch (e) { caught = e.message }
const r = await tools.b.tool({})
return caught + " / " + r
`,
},
ctx,
),
)
// 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"])
})
test("streams live per-call metadata as a call starts and finishes", async () => {
const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record<string, unknown> }[] }> = []
const recordingCtx: Tool.Context = {
...ctx,
metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)),
}
const tool = await build({ greeter_hello: mcpTool("hello", () => ({ content: [{ type: "text", text: "hi" }] })) })
await Effect.runPromise(
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" } }],
})
expect(snapshots).toContainEqual({
toolCalls: [{ tool: "greeter.hello", status: "completed", input: { name: "Ada" } }],
})
})
test("marks a failed child call as error in the live metadata", async () => {
const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record<string, unknown> }[] }> = []
const recordingCtx: Tool.Context = {
...ctx,
metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)),
}
const tool = await build({
bad_tool: mcpTool("tool", () => ({ isError: true, content: [{ type: "text", text: "boom" }] })),
})
await Effect.runPromise(
tool.execute(
{ code: "try { await tools.bad.tool({ reason: 'test' }) } catch (e) { return 'caught' }" },
recordingCtx,
),
)
expect(snapshots).toContainEqual({ toolCalls: [{ tool: "bad.tool", status: "error", input: { reason: "test" } }] })
})
test("accumulates stripped media as execute attachments the sandbox never sees", async () => {
const tool = await build({
shot_take: mcpTool("take", () => ({
content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }],
structuredContent: { name: "shot.png" },
})),
})
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")
})
test("a media-only result returns a text marker so the program knows it succeeded", 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: "return await tools.shot.take({})" }, ctx))
expect(out.output).toBe("[1 image attached to the result]")
expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,PNGDATA" }])
})
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),
)
expect(out.output).toBe("captured")
expect(out.attachments).toHaveLength(1)
})
test("isolates the sandbox from host globals", async () => {
const tool = await build({})
const output = await Effect.runPromise(tool.execute({ code: "return process.env" }, ctx))
expect(output.metadata.error).toBe(true)
})
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", () => {
controller.abort()
return new Promise(() => {})
}),
})
const output = await Effect.runPromise(
tool.execute(
{ code: "try { await tools.host.trigger({}) } catch {} while (true) {}" },
{ ...ctx, abort: controller.signal },
),
)
expect(output.output).toBe("Execution cancelled.")
expect(output.metadata.error).toBe(true)
expect(output.metadata.toolCalls).toEqual([{ tool: "host.trigger", status: "running" }])
})
test("a pre-aborted signal cancels before the program runs", async () => {
const controller = new AbortController()
controller.abort()
const ran: string[] = []
const tool = await build({ host_touch: mcpTool("touch", () => (ran.push("called"), "ok")) })
const output = await Effect.runPromise(
tool.execute({ code: "return await tools.host.touch({})" }, { ...ctx, abort: controller.signal }),
)
expect(output.output).toBe("Execution cancelled.")
expect(ran).toEqual([])
})
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()
expect(output.output).not.toContain("[result truncated:")
expect(output.output.length).toBeGreaterThanOrEqual(40_000)
})
test("appends logs after the result on success and after the message on error", async () => {
const tool = await build({})
const ok = await Effect.runPromise(
tool.execute({ code: "console.log('step one'); console.warn('careful'); return 'done'" }, ctx),
)
expect(ok.output).toBe("done\n\nLogs:\nstep one\n[warn] careful")
const err = await Effect.runPromise(
tool.execute({ code: "console.log('before the throw'); throw new Error('boom')" }, ctx),
)
expect(err.metadata.error).toBe(true)
expect(err.output).toContain("Uncaught: boom")
expect(err.output).toContain("Logs:\nbefore the throw")
})
})
describe("code mode permission visibility", () => {
const deny = (permission: string): PermissionV1.Rule => ({ permission, pattern: "*", action: "deny" })
const askRule = (permission: string): PermissionV1.Rule => ({ permission, pattern: "*", action: "ask" })
const ok = () => ({ content: [{ type: "text", text: "ok" }] })
test("a hard-denied tool never enters the catalog or its search index", () => {
const mcpTools = {
github_create_issue: mcpTool("create_issue", ok),
github_list_issues: mcpTool("list_issues", ok),
}
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)")
})
test("an ask-level tool stays fully visible in the catalog", () => {
const mcpTools = {
github_create_issue: mcpTool("create_issue", ok),
github_list_issues: mcpTool("list_issues", ok),
}
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)")
})
test("a hard-denied tool is not dispatchable: the program gets the unknown-tool diagnostic", async () => {
const called: string[] = []
const tool = await build(
{
github_create_issue: mcpTool("create_issue", () => {
called.push("create_issue")
return ok()
}),
github_list_issues: mcpTool("list_issues", ok),
},
{},
["github"],
[deny("github_create_issue")],
)
const denied = await Effect.runPromise(
tool.execute({ code: "return await tools.github.create_issue({ title: 'x' })" }, ctx),
)
expect(denied.metadata.error).toBe(true)
expect(denied.output).toContain("Unknown tool 'github.create_issue'")
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),
)
expect(allowed.metadata.error).toBeUndefined()
expect(allowed.output).toBe("ok")
})
test("an ask-level tool remains callable and still prompts via ctx.ask", async () => {
const asked: string[] = []
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),
)
expect(out.output).toBe("ok")
expect(asked).toEqual(["github_list_issues"])
})
test("Permission.visibleTools hides only hard denies, matching Permission.disabled", () => {
const tools = { a_tool: 1, b_tool: 2, c_tool: 3 }
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")
})
})

View file

@ -17,6 +17,8 @@ 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"
@ -57,6 +59,45 @@ 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()
})
@ -491,3 +532,82 @@ 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)")
}),
)
})

View file

@ -1758,6 +1758,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
<Match when={display() === "task"}>
<Task {...toolprops} />
</Match>
<Match when={display() === "execute"}>
<Execute {...toolprops} />
</Match>
<Match when={display() === "apply_patch"}>
<ApplyPatch {...toolprops} />
</Match>
@ -2322,6 +2325,75 @@ export function formatCompletedSubagentDetail(toolcalls: number, duration: strin
return `${formatSubagentToolcalls(toolcalls)} · ${duration}`
}
type ExecuteCall = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }
function executeCalls(value: unknown): ExecuteCall[] {
if (!Array.isArray(value)) return []
return value.flatMap((call) => {
const item = recordValue(call)
const tool = stringValue(item?.tool)
const status = stringValue(item?.status)
if (!tool || !status || !["running", "completed", "error"].includes(status)) return []
return [{ tool, status: status as ExecuteCall["status"], input: recordValue(item?.input) }]
})
}
// The `execute` tool streams child tool calls through metadata, not a child session like Task.
function Execute(props: ToolProps) {
const ctx = use()
const { theme } = useTheme()
const isLoading = createMemo(() => props.part.state.status === "pending" || props.part.state.status === "running")
const calls = createMemo(() => executeCalls(props.metadata.toolCalls))
const output = createMemo(() => stripAnsi(props.output?.trim() ?? ""))
const hasRuntimeError = createMemo(() => props.metadata.error === true)
const outputPreview = createMemo(() => collapseToolOutput(output(), 4, 4 * Math.max(20, ctx.width - 6)).output)
const showOutput = createMemo(() => output() && (hasRuntimeError() || calls().length === 0))
const detailPadding = 3 + INLINE_TOOL_ICON_WIDTH
const complete = createMemo(() => (props.part.state.status === "pending" ? false : "execute"))
return (
<>
<InlineTool
icon={props.part.state.status === "completed" && !hasRuntimeError() ? "✓" : props.part.state.status === "error" || hasRuntimeError() ? "✗" : "│"}
color={hasRuntimeError() ? theme.error : undefined}
spinner={isLoading()}
pending="execute"
complete={complete()}
part={props.part}
>
execute
</InlineTool>
<For each={calls()}>
{(call) => {
const args = input(call.input ?? {})
return (
<box paddingLeft={detailPadding}>
<text fg={call.status === "error" ? theme.error : theme.textMuted}>
{call.tool}
{args ? ` ${args}` : ""}
{call.status === "error" ? " (failed)" : ""}
</text>
</box>
)
}}
</For>
<Show when={showOutput()}>
<box paddingLeft={detailPadding}>
<For each={outputPreview().split("\n")}>
{(line, index) => (
<text fg={hasRuntimeError() ? theme.error : theme.textMuted}>
{index() === 0 ? "↳ " : " "}
{line}
</text>
)}
</For>
</box>
</Show>
</>
)
}
function Edit(props: ToolProps) {
const ctx = use()
const { theme, syntax } = useTheme()
@ -2578,6 +2650,7 @@ const toolDisplays = new Set([
"todowrite",
"question",
"skill",
"execute",
])
export function toolDisplay(tool: string) {