Compare commits

..

16 Commits

Author SHA1 Message Date
Aiden Cline ef30690016 refactor(codemode): simplify tool instructions 2026-07-24 00:57:28 -05:00
Aiden Cline d811cba574 wip 2026-07-23 23:47:32 -05:00
Aiden Cline 1e541a8106 refactor(core): simplify code mode catalog 2026-07-23 19:07:27 -05:00
Aiden Cline 39a711cd56 refactor(core): trim catalog comments and rename budget planner 2026-07-23 17:17:13 -05:00
Aiden Cline 12c5984aa1 fix(core): sort catalog snapshot by code units for stable hashing 2026-07-23 16:37:44 -05:00
Aiden Cline cbd35e7401 feat(core): render CodeMode catalog deltas from structured snapshots 2026-07-23 16:37:43 -05:00
opencode-agent[bot] 02f2725154 chore: merge dev into v2 (#38563)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Sebastian <hasta84@gmail.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Test User <test@test.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com>
Co-authored-by: Qiping Li <liqiping1991@gmail.com>
Co-authored-by: liqiping <liqiping@msh.team>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com>
Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com>
Co-authored-by: Daniel Polito <danielbpolito@gmail.com>
Co-authored-by: opencode <noreply@opencode.ai>
2026-07-23 16:20:37 -05:00
Kit Langton 79c1544072 refactor(tools): unify tool APIs and result handling (#38367) 2026-07-23 21:13:31 +00:00
Aiden Cline 8cac010bac fix(core): stop forcing toolChoice none on session.generate (#38557) 2026-07-23 14:12:23 -05:00
Aiden Cline 193f6be99c fix(ai): keep tools when Gemini tool choice is none (#38556) 2026-07-23 14:09:25 -05:00
opencode-agent[bot] 2a9f8e3a2c fix(tui): manage focus in devtools panels (#38555)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-07-23 18:58:02 +00:00
Aiden Cline 2c814120c7 fix(ai): keep tools when Anthropic tool_choice is none (#38553) 2026-07-23 13:52:20 -05:00
opencode-agent[bot] 18fccac6ff fix(tui): preserve first message in new sessions (#38542)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-07-23 13:59:51 -04:00
opencode-agent[bot] 360e7b412d feat(tui): expose debug settings (#38546)
Co-authored-by: James Long <longster@gmail.com>
2026-07-23 13:37:16 -04:00
opencode-agent[bot] ad596fb42b chore(core): upgrade fff to 0.10.1 (#38545)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-23 12:34:24 -05:00
opencode-agent[bot] 74e92f73e0 refactor(ai): remove unused response format (#38540)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-23 12:32:33 -05:00
174 changed files with 6520 additions and 3333 deletions
+8
View File
@@ -0,0 +1,8 @@
---
"@opencode-ai/plugin": minor
"@opencode-ai/sdk": minor
"@opencode-ai/client": minor
"@opencode-ai/protocol": minor
---
Replace the V2 tool result model with one canonical representation per fact. Tools lose `structured`, projection callbacks, the `Structured` generic, and the exported `Tool.settle` interpreter; tool responses carry schema-validated `output`, model-visible `content`, and optional compact JSON `metadata`. Code Mode receives the validated encoded output. Durable tool success stores non-empty model content plus optional metadata; failure stores one error plus the final bounded partial snapshot. Progress carries metadata only, while `execute.after` hooks receive the canonical terminal outcome and managed `outputPaths`. A one-time migration rewrites existing projected tool rows and moves provider-hosted result payloads into provider-owned result state.
+30 -13
View File
@@ -61,6 +61,7 @@
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
@@ -350,7 +351,7 @@
"@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/mistral": "3.0.51",
"@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
@@ -362,7 +363,7 @@
"@aws-sdk/credential-providers": "3.1057.0",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@ff-labs/fff-bun": "0.9.4",
"@ff-labs/fff-bun": "0.10.1",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/ai": "workspace:*",
@@ -470,7 +471,9 @@
"packages/docs": {
"name": "@opencode-ai/docs",
"devDependencies": {
"effect": "catalog:",
"mint": "4.2.666",
"prettier": "3.6.2",
},
},
"packages/effect-drizzle-sqlite": {
@@ -706,6 +709,7 @@
"version": "1.18.4",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13.tgz",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -1079,6 +1083,7 @@
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
@@ -1205,7 +1210,7 @@
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
@@ -1641,23 +1646,25 @@
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
"@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xyivu2xB++O5xXDx5Qm50JsU2aXt8YgXlGVhH/HE7UMYDrE6L6f1RYdYs8Y0bn0D3D0+bFBrN5ELPszK9E4Wbw=="],
"@ff-labs/fff-bin-android-arm64": ["@ff-labs/fff-bin-android-arm64@0.10.1", "", { "os": "android", "cpu": "arm64" }, "sha512-6Bsaa6yKEd2HV1M2WtqSYhoZucKYffIUms6GYoPN48QHP8hZgO8GXJ85/JDxKlkCJsN2hw1ROiwQK0+xUHYhFQ=="],
"@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xLooAhCnTDCipPSMMZz7kGF3lhRHx6aP5fb6DJ0Ipyw/w/UWJb+xITJFszUl/QnIBoJ/qjDc93/FZMo1dk6gVA=="],
"@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.10.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7yUP+56sG3UTrLg7eepOD16yM2dgiD7g6Ase2XWQB7oXwLV7mBMylPKIli2pP3kHzfw9K+BS3WAwHKHJ2QhxYw=="],
"@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-m5+8vA+1veaUUWonwva1WsU6m1HRm8CpYUzr06KDB65mewlmPbqz7+Fh7hjEfiD8C4mHVHe6RysULvAH1yhsdw=="],
"@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.10.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-9zb+P1xtyqu/jVklm5RKF2zm9doRRsBNbkF/a8S4aSqSJoNlQR8ZF7C129fzOLfffVAjjgcO2l8oJgxOzHYiwQ=="],
"@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-EMeWm7CSTVkizy4ZEzUkLDP024tVcbCUthduuIhekFQRDsiaAze0YboIylWb9HBHJCZlCCoZrWAl4nnJbsX7AA=="],
"@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-KTwr9CUfCJv0vtG2xG+nMxDae/2NJY2/oVmtgSvTnH9baI6JprqsFGphbukx7mdW/QMPwBiYtoO8ZGoC7i92jA=="],
"@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-pglE0uLkhnlE6bStXqfgUjYTSj+2sVwXaPfoA0QksidAsQor6NRt8004mygzC9DPubgHq5B9QezPfEwigKaP9Q=="],
"@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-oznmSpV+zjiAPiwqbA4y+SAsMaYvDhGllHiT9L4ebdQnSOfcQzlUwvo45OhBPwHBt47tIois4bkF/MITlDk54A=="],
"@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-VNKxgl8qs3aTfXViX7lqRK1aLu311h8dtBFqG4Scv+9Oi7WprybUp5L7IZ8sxKERaDAaiJMXHodXa1c90QdK8w=="],
"@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-KDpl8lwSEOauP/6FSJIvnARzE+ILm2rVIRQAio9dc5nn56EvlsryBWry0c5V0Tbw1PV0lNrZ+bzcIiPuTacvzw=="],
"@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-uFEt0aNL54vQxq1ivjxRuo+thnhS4wLqa4INl4VXnXJUmwB42XXxD+gsj7vzhBLLx4cFf0aWgy/+TVDR8yjZtQ=="],
"@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-mZCpojVtGNDr/wdCUEAHdfrl6qORvKHv3Pw35TaOdshnG4wocoA5bBKTGj/zmAU11o0GMK4V+wUhdNrgqoE10w=="],
"@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Yd2Eyxj+slWv+0QDW9/xBpu9FXq+hwD0rXQD5184/88d+xwWCLKhEP2w8I6OO9XCg+kLT79UJb+k0WwXUtBtMw=="],
"@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.10.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-SbT76ETXC5AgV9J8sVDozKG8wzrrxsOn8lbBprlOtx+O5V5EYmS1W7BlsatTHy6ddQ3uU5oOYO04PWZBuP6wXg=="],
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.9.4", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.9.4", "@ff-labs/fff-bin-darwin-x64": "0.9.4", "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-arm64-musl": "0.9.4", "@ff-labs/fff-bin-linux-x64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-x64-musl": "0.9.4", "@ff-labs/fff-bin-win32-arm64": "0.9.4", "@ff-labs/fff-bin-win32-x64": "0.9.4" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-7HUraaK/g5dStAnuKAuzsXVOQvqqX0ylo5G+DxYwsCjCDc42bjoEAAHqz/3Sn3raUNw97KMoz87XR9QyrLEfVw=="],
"@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-dcpHUCBEZoXKQCdKa3bADfgcNyeyfM8tatXrILqIFYi9GL8E4GnzKx1rGkgP5ukVtuj0WuoJyyqSvGjpJPIT8w=="],
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.10.1", "", { "optionalDependencies": { "@ff-labs/fff-bin-android-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-x64": "0.10.1", "@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-arm64-musl": "0.10.1", "@ff-labs/fff-bin-linux-x64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-x64-musl": "0.10.1", "@ff-labs/fff-bin-win32-arm64": "0.10.1", "@ff-labs/fff-bin-win32-x64": "0.10.1" }, "os": [ "!aix", "!sunos", "!freebsd", "!openbsd", ], "cpu": [ "x64", "arm64", ] }, "sha512-9oUCxypGbf2q3vNfKZ31wdzt5KqjhA9S6TwQaFol/j1lkSHVGvtIa3RvdGHPs1UUuvR/MO7b6pHj054BR9bXPQ=="],
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
@@ -6149,7 +6156,9 @@
"@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
@@ -6579,6 +6588,8 @@
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="],
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
@@ -6597,6 +6608,8 @@
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
"@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="],
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/storybook/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="],
@@ -6925,6 +6938,8 @@
"aggregate-error/indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="],
"ai-gateway-provider/@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -8045,6 +8060,8 @@
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-qt11SKmOjq0KU542QFbs+u7YyJicn4drCcwCdg325yk=",
"aarch64-linux": "sha256-z68doReXTrWS7HeiAjc0btIjAsvzeZZ7hXAlHr0c77Q=",
"aarch64-darwin": "sha256-PILYH1Pi8XBvSkuZ+1sNnUTao5kba+m5Z8iJKx6YXPo=",
"x86_64-darwin": "sha256-KpcJzP4m0SUavu/WaSffgzOxrHq8ljdy0GOzs9p16lo="
"x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=",
"aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=",
"aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=",
"x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ="
}
}
+1 -1
View File
@@ -155,12 +155,12 @@
"@types/node": "catalog:"
},
"patchedDependencies": {
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
+1 -1
View File
@@ -81,7 +81,7 @@ const GENERATE_OBJECT_TOOL_NAME = "generate_object"
const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool."
type GenerateObjectBase = Omit<RequestInput, "tools" | "toolChoice" | "responseFormat">
type GenerateObjectBase = Omit<RequestInput, "tools" | "toolChoice">
export class GenerateObjectResponse<T> {
constructor(
@@ -159,7 +159,7 @@ const AnthropicTool = Schema.Struct({
type AnthropicTool = Schema.Schema.Type<typeof AnthropicTool>
const AnthropicToolChoice = Schema.Union([
Schema.Struct({ type: Schema.Literals(["auto", "any"]) }),
Schema.Struct({ type: Schema.Literals(["auto", "any", "none"]) }),
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }),
])
@@ -297,7 +297,7 @@ const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSc
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, {
auto: () => ({ type: "auto" as const }),
none: () => undefined,
none: () => ({ type: "none" as const }),
required: () => ({ type: "any" as const }),
tool: (name) => ({ type: "tool" as const, name }),
})
@@ -330,7 +330,10 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
const wireType = serverToolResultType(part.name)
if (!wireType)
return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`)
return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock
// Prefer the provider-owned replay payload; fall back to the result value for
// histories constructed directly from provider events.
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
return { type: wireType, tool_use_id: part.id, content: payload } satisfies AnthropicServerToolResultBlock
})
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
@@ -542,7 +545,6 @@ const outputConfig = (request: LLMRequest) => {
}
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096
@@ -551,7 +553,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
// over-mark we keep their tool hints and shed the message-tail ones first.
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
const tools =
request.tools.length === 0 || request.toolChoice?.type === "none"
request.tools.length === 0
? undefined
: request.tools.map((tool) =>
lowerTool(
@@ -560,6 +562,9 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
),
)
// Anthropic rejects tool_choice when tools are absent; "none" is only meaningful with tools present.
const toolChoice =
tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice)
const system =
request.system.length === 0
? undefined
@@ -680,7 +685,9 @@ const serverToolResultEvent = (block: NonNullable<AnthropicEvent["content_block"
name: SERVER_TOOL_RESULT_NAMES[block.type],
result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content },
providerExecuted: true,
providerMetadata: anthropicMetadata({ blockType: block.type }),
// The complete payload is irreducible provider replay state: subsequent
// stateless requests must round-trip the typed result block verbatim.
providerMetadata: anthropicMetadata({ blockType: block.type, result: block.content }),
})
}
@@ -392,8 +392,13 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
// tools → system → messages order to favour the highest-impact prefixes.
const breakpoints = BedrockCache.breakpoints()
const toolConfig =
request.tools.length > 0 && request.toolChoice?.type !== "none"
? { tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools), toolChoice }
request.tools.length > 0
? {
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools),
// Converse has no native "none". Keep definitions stable for prompt
// caching and omit only the unsupported choice.
toolChoice,
}
: undefined
const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
const messages = yield* lowerMessages(request, breakpoints)
+3 -3
View File
@@ -313,7 +313,7 @@ const thinkingConfig = (request: LLMRequest) => {
}
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none"
const hasTools = request.tools.length > 0
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const generationConfig = {
@@ -329,7 +329,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
contents: yield* lowerMessages(request),
systemInstruction:
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
tools: toolsEnabled
tools: hasTools
? [
{
functionDeclarations: request.tools.map((tool) =>
@@ -338,7 +338,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
},
]
: undefined,
toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined,
toolConfig: hasTools && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined,
generationConfig: Object.values(generationConfig).some((value) => value !== undefined)
? generationConfig
: undefined,
-9
View File
@@ -261,13 +261,6 @@ export namespace ToolChoice {
}
}
export const ResponseFormat = Schema.Union([
Schema.Struct({ type: Schema.Literal("text") }),
Schema.Struct({ type: Schema.Literal("json"), schema: JsonSchema }),
Schema.Struct({ type: Schema.Literal("tool"), tool: ToolDefinition }),
]).pipe(Schema.toTaggedUnion("type"))
export type ResponseFormat = Schema.Schema.Type<typeof ResponseFormat>
export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
id: Schema.optional(Schema.String),
model: ModelSchema,
@@ -278,7 +271,6 @@ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
generation: Schema.optional(GenerationOptions),
providerOptions: Schema.optional(ProviderOptions),
http: Schema.optional(HttpOptions),
responseFormat: Schema.optional(ResponseFormat),
cache: Schema.optional(CachePolicy),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
@@ -296,7 +288,6 @@ export namespace LLMRequest {
generation: request.generation,
providerOptions: request.providerOptions,
http: request.http,
responseFormat: request.responseFormat,
cache: request.cache,
metadata: request.metadata,
})
@@ -235,6 +235,34 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("keeps tools and sends tool_choice none", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
id: "req_tool_choice_none",
model,
tools: [{ name: "lookup", description: "Look things up", inputSchema: { type: "object", properties: {} } }],
messages: [
Message.user("What is the weather?"),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
],
toolChoice: "none",
cache: "none",
}),
)
expect(prepared.body.tools).toEqual([
{
name: "lookup",
description: "Look things up",
input_schema: { type: "object", properties: {} },
},
])
expect(prepared.body.tool_choice).toEqual({ type: "none" })
}),
)
// Regression: read tool results must stay structured so base64 media data is
// not JSON-stringified into `tool_result.content`.
it.effect("lowers media tool-result content as structured blocks", () =>
@@ -636,7 +664,14 @@ describe("Anthropic Messages route", () => {
name: "web_search",
result: { type: "json", value: [{ type: "web_search_result", url: "https://example.com", title: "Example" }] },
providerExecuted: true,
providerMetadata: { anthropic: { blockType: "web_search_tool_result" } },
// The complete payload rides in provider metadata as irreducible replay
// state for later stateless requests.
providerMetadata: {
anthropic: {
blockType: "web_search_tool_result",
result: [{ type: "web_search_result", url: "https://example.com", title: "Example" }],
},
},
})
expect(response.text).toBe("Found it.")
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
@@ -154,6 +154,36 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("keeps tools and omits the unsupported choice when tool choice is none", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.updateRequest(baseRequest, {
tools: [
{
name: "lookup",
description: "Lookup data",
inputSchema: { type: "object", properties: { query: { type: "string" } } },
},
],
toolChoice: ToolChoice.make({ type: "none" }),
}),
)
expect(prepared.body.toolConfig).toMatchObject({
tools: [
{
toolSpec: {
name: "lookup",
description: "Lookup data",
inputSchema: { json: { type: "object", properties: { query: { type: "string" } } } },
},
},
],
})
expect(prepared.body.toolConfig?.toolChoice).toBeUndefined()
}),
)
it.effect("lowers assistant tool-call + tool-result message history", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
+5 -3
View File
@@ -233,11 +233,11 @@ describe("Gemini route", () => {
}),
)
it.effect("omits tools when tool choice is none", () =>
it.effect("keeps tools and sends function calling mode NONE", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
id: "req_no_tools",
id: "req_tool_choice_none",
model,
prompt: "Say hello.",
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
@@ -245,8 +245,10 @@ describe("Gemini route", () => {
}),
)
expect(prepared.body).toEqual({
expect(prepared.body).toMatchObject({
contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
tools: [{ functionDeclarations: [{ name: "lookup", description: "Lookup data" }] }],
toolConfig: { functionCallingConfig: { mode: "NONE" } },
})
}),
)
@@ -97,6 +97,7 @@ export async function setupTimeline(
locale?: string
deviceScaleFactor?: number
seedHistory?: boolean
protocol?: "v1" | "v2"
} = {},
) {
const sessions = input.sessions ?? [session()]
@@ -114,6 +115,7 @@ export async function setupTimeline(
retry: input.eventRetry ?? 20,
})
await mockOpenCodeServer(page, {
protocol: input.protocol,
directory,
project: project(),
provider: provider(),
@@ -89,8 +89,8 @@ test("reconnects after a stream error", async ({ page }) => {
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
})
test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10 })
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
id: "timeline-event-7",
})
@@ -100,7 +100,7 @@ test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) =
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
expect(first.eventID).toBe("timeline-event-7")
expect(connection.headers["last-event-id"]).toBe("timeline-event-7")
expect(connection.headers["last-event-id"]).toBeUndefined()
})
test("passes through non-event fetches", async ({ page }) => {
+276 -2
View File
@@ -4,6 +4,7 @@ const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/sta
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
export interface MockServerConfig {
protocol?: "v1" | "v2"
provider: unknown
directory: string
project: unknown
@@ -53,8 +54,20 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
if (path === "/global/health") return json(route, { healthy: true })
if (path === "/global/event" || path === "/event" || path === "/api/event") {
const events = config.events?.()
return sse(
route,
path === "/api/event"
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
: events,
config.eventRetry,
)
}
if (path === "/global/health")
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
if (path === "/api/health" && config.protocol === "v2")
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
if (path === "/permission")
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
@@ -83,10 +96,129 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
},
data: [],
})
if (path === "/api/agent")
return json(route, {
location: location(config),
data: [
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
},
],
})
if (path === "/api/command") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp/resource")
return json(route, { location: location(config), data: { resources: [], templates: [] } })
const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1]
if (integration && route.request().method() === "GET")
return json(route, {
location: location(config),
data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
})
if (/^\/api\/integration\/[^/]+\/connect\/key$/.test(path) && route.request().method() === "POST")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/project") return json(route, [config.project])
if (path === "/api/project/current")
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project)
if (path === "/api/path")
return json(route, {
state: config.directory,
config: config.directory,
worktree: config.directory,
directory: config.directory,
home: "C:/OpenCode",
})
if (path === "/api/permission/request")
return json(route, {
location: location(config),
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
currentPermission,
),
})
if (path === "/api/question/request")
return json(route, {
location: location(config),
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
})
if (path === "/api/vcs")
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, [])
if (path === "/api/session") {
const directory = url.searchParams.get("directory")
const parentID = url.searchParams.get("parentID")
const limit = Number(url.searchParams.get("limit") ?? 50)
const offset = Number(url.searchParams.get("cursor") ?? 0)
const sessions = config.sessions
.filter((session) => !directory || session.directory === directory)
.filter((session) => parentID !== "null" || session.parentID === undefined)
.filter((session) => {
const search = url.searchParams.get("search")?.toLowerCase()
return (
!search ||
String(session.title ?? "")
.toLowerCase()
.includes(search)
)
})
const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions
const data = ordered.slice(offset, offset + limit)
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
return json(route, {
data: data.map((session) => currentSession(session, config.directory)),
cursor: { next },
})
}
if (path === "/api/session/active") {
const statuses = (config.sessionStatus ?? {}) as Record<string, { type?: string }>
return json(route, {
data: Object.fromEntries(
Object.entries(statuses).flatMap(([id, status]) =>
status.type === "idle" ? [] : [[id, { type: "running" }]],
),
),
})
}
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
route.request().method() === "POST"
) {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (path in staticRoutes) return json(route, staticRoutes[path])
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
if (currentSessionMatch) {
const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
if (!session) return json(route, { error: "Session not found" }, undefined, 404)
return json(route, {
data: currentSession(session, config.directory),
})
}
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
if (sessionMatch) {
const session = config.sessions.find((s) => s.id === sessionMatch[1])
@@ -107,6 +239,24 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
if (currentMessagesMatch) {
const token = url.searchParams.get("cursor") ?? undefined
const before = token ? cursors.get(token) : undefined
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" })
await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" })
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
if (cursor) cursors.set(cursor, pageData.cursor!)
return json(route, {
data: pageData.items.map(currentMessage).reverse(),
cursor: { next: cursor },
})
}
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
if (messagesMatch) {
const token = url.searchParams.get("before") ?? undefined
@@ -129,6 +279,115 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
})
}
function location(config: MockServerConfig) {
return {
directory: config.directory,
project: { id: (config.project as { id?: string }).id, directory: config.directory },
}
}
function currentPermission(value: unknown) {
const permission = value as Record<string, unknown>
if (permission.action) return permission
const tool = permission.tool as { messageID?: string; callID?: string } | undefined
return {
id: permission.id,
sessionID: permission.sessionID,
action: permission.permission,
resources: permission.patterns ?? [],
save: permission.always,
metadata: permission.metadata,
source:
tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
}
}
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
const time = session.time && typeof session.time === "object" ? session.time : {}
return {
id: session.id,
parentID: session.parentID,
projectID: session.projectID ?? "project",
agent: session.agent ?? "build",
model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
cost: session.cost ?? 0,
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: {
created: "created" in time && typeof time.created === "number" ? time.created : 0,
updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0,
...(session.time && typeof session.time === "object" && "archived" in session.time
? { archived: session.time.archived }
: {}),
},
title: session.title ?? session.id,
location: {
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
},
subpath: session.path,
revert: session.revert,
}
}
function currentMessage(value: unknown) {
const item = value as {
info: Record<string, unknown> & { id: string; role: "user" | "assistant"; time: { created: number } }
parts: Array<Record<string, unknown> & { type: string }>
}
if (item.info.role === "user") {
return {
id: item.info.id,
type: "user",
time: item.info.time,
text: item.parts
.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
.join("\n"),
}
}
return {
id: item.info.id,
type: "assistant",
time: item.info.time,
agent: item.info.agent ?? "build",
model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" },
cost: item.info.cost,
tokens: item.info.tokens,
error: item.info.error,
content: item.parts.flatMap<unknown>((part) => {
if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }]
if (part.type !== "tool") return []
const state = part.state as Record<string, unknown>
return [
{
type: "tool",
id: part.id,
name: part.tool,
time: state.time ?? { created: item.info.time.created },
state:
state.status === "pending"
? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) }
: state.status === "completed"
? {
status: "completed",
input: state.input ?? {},
structured: state.metadata ?? {},
content: [{ type: "text", text: state.output ?? "" }],
}
: state.status === "error"
? {
status: "error",
input: state.input ?? {},
structured: state.metadata ?? {},
content: [],
error: { type: "ToolError", message: state.error ?? "Tool failed" },
}
: { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] },
},
]
}),
}
}
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
return route.fulfill({
status,
@@ -149,3 +408,18 @@ function sse(route: Route, events?: unknown[], retry?: number) {
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
})
}
function currentEvent(input: unknown) {
if (!input || typeof input !== "object" || !("payload" in input)) return input
const envelope = input as { directory?: string; payload?: unknown }
if (!envelope.payload || typeof envelope.payload !== "object") return input
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
if (!payload.type) return input
return {
id: payload.id ?? `evt_mock_${Date.now()}`,
created: Date.now(),
type: payload.type,
data: payload.properties ?? {},
location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
}
}
+29 -9
View File
@@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
export type SseConnectionRecord = {
id: number
url: string
path: "/global/event" | "/event"
path: "/global/event" | "/event" | "/api/event"
headers: Record<string, string>
openedAt: number
endedAt?: number
@@ -93,6 +93,21 @@ export async function installSseTransport<T>(
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
`data: ${JSON.stringify(payload)}\n\n`,
].join("")
const currentEvent = (input: unknown) => {
if (!input || typeof input !== "object" || !("payload" in input)) return input
const envelope = input as { directory?: string; payload?: unknown }
if (!envelope.payload || typeof envelope.payload !== "object") return input
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
if (!payload.type) return input
return {
id: payload.id ?? `evt_mock_${Date.now()}`,
created: Date.now(),
type: payload.type,
data: payload.properties ?? {},
location:
envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
}
}
const acknowledge = (
connection: Connection,
bytes: number,
@@ -140,15 +155,13 @@ export async function installSseTransport<T>(
output.forEach((chunk) => connection.controller.enqueue(chunk))
return acknowledge(connection, input.bytes.length, output.length)
}
const encoded = input.deliveries.map((delivery) => ({
delivery,
bytes: encoder.encode(frame(delivery.payload, delivery.options)),
}))
const encoded = input.deliveries.map((delivery) => {
const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload
return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) }
})
encoded.forEach((item) => marker(item.delivery.options?.marker))
if (input.burst) {
const bytes = encoder.encode(
encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""),
)
const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join(""))
connection.controller.enqueue(bytes)
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
}
@@ -161,7 +174,10 @@ export async function installSseTransport<T>(
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
const url = new URL(request.url)
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
if (
url.origin !== server ||
(url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
)
return originalFetch(request)
const id = ++nextConnectionID
@@ -177,6 +193,10 @@ export async function installSseTransport<T>(
record.controller = controller
connections.push(record)
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
if (url.pathname === "/api/event")
controller.enqueue(
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
)
request.signal.addEventListener(
"abort",
() => {
+1
View File
@@ -53,6 +53,7 @@
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
+37 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { Event } from "@opencode-ai/sdk/v2/client"
describe("resumeStreamAfterPageShow", () => {
@@ -14,6 +15,23 @@ describe("resumeStreamAfterPageShow", () => {
})
})
describe("adaptServerEvent", () => {
test("preserves V2 events while adapting permission requests for existing consumers", () => {
const current = {
id: "evt_1",
created: 1,
type: "permission.v2.asked",
data: { id: "perm_1", sessionID: "ses_1", action: "read", resources: ["src/**"] },
} as OpenCodeEvent
expect(adaptServerEvent(current)).toMatchObject({
type: "permission.asked",
properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] },
current,
})
})
})
describe("coalesceServerEvents", () => {
const delta = (value: string, field = "text", partID = "part") => ({
directory: "/repo",
@@ -34,6 +52,24 @@ describe("coalesceServerEvents", () => {
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
})
test("merges adjacent current text deltas", () => {
const current = (id: string, value: string) =>
adaptServerEvent({
id,
created: 1,
type: "session.text.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value },
} as OpenCodeEvent)
const result = coalesceServerEvents([
{ directory: "/repo", payload: current("evt_1", "hello ") },
{ directory: "/repo", payload: current("evt_2", "world") },
])
expect(result).toHaveLength(1)
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
})
test("preserves event boundaries and distinct fields", () => {
const status = {
directory: "/repo",
+143 -51
View File
@@ -1,21 +1,60 @@
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
import { createSdkForServer } from "@/utils/server"
import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
import { ServerConnection, useServer } from "./server"
import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global"
import { ServerScope } from "@/utils/server-scope"
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
type QueuedServerEvent = { directory: string; payload: Event }
export type ServerEvent = Event & { current?: OpenCodeEvent }
type QueuedServerEvent = { directory: string; payload: ServerEvent }
type CurrentDelta = Extract<
OpenCodeEvent,
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
>
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
if (event.type === "permission.v2.asked") {
return {
id: event.id,
type: "permission.asked",
properties: {
id: event.data.id,
sessionID: event.data.sessionID,
permission: event.data.action,
patterns: event.data.resources,
always: event.data.save ?? [],
metadata: event.data.metadata ?? {},
tool:
event.data.source?.type === "tool"
? { messageID: event.data.source.messageID, callID: event.data.source.callID }
: undefined,
},
current: event,
} as ServerEvent
}
if (event.type === "permission.v2.replied")
return { id: event.id, type: "permission.replied", properties: event.data, current: event } as ServerEvent
if (event.type === "question.v2.asked")
return { id: event.id, type: "question.asked", properties: event.data, current: event } as ServerEvent
if (event.type === "question.v2.replied")
return { id: event.id, type: "question.replied", properties: event.data, current: event } as ServerEvent
if (event.type === "question.v2.rejected")
return { id: event.id, type: "question.rejected", properties: event.data, current: event } as ServerEvent
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
}
const coalescedKey = (event: QueuedServerEvent) => {
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
@@ -40,6 +79,34 @@ export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServ
export function coalesceServerEvents(events: QueuedServerEvent[]) {
const output: QueuedServerEvent[] = []
events.forEach((event) => {
const current = currentDelta(event.payload.current)
if (current) {
const previous = output[output.length - 1]
const prior = currentDelta(previous?.payload.current)
if (
previous &&
prior &&
previous.directory === event.directory &&
currentDeltaKey(prior) === currentDeltaKey(current)
) {
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
const data =
current.type === "session.compaction.delta"
? { ...current.data, text: fragment }
: { ...current.data, delta: fragment }
output[output.length - 1] = {
directory: event.directory,
payload: {
...event.payload,
properties: data,
current: { ...current, data } as CurrentDelta,
} as ServerEvent,
}
return
}
output.push(event)
return
}
if (event.payload.type !== "message.part.delta") {
output.push(event)
return
@@ -71,12 +138,52 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
return output
}
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
if (
event?.type === "session.text.delta" ||
event?.type === "session.reasoning.delta" ||
event?.type === "session.tool.input.delta" ||
event?.type === "session.compaction.delta"
)
return event
}
function currentDeltaKey(event: CurrentDelta) {
if (event.type === "session.tool.input.delta")
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.callID}`
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
}
function currentDeltaFragment(event: CurrentDelta) {
return event.type === "session.compaction.delta" ? event.data.text : event.data.delta
}
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
if (!event.persisted) return
start()
}
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) {
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
type ServerSDKBase = {
server: ServerConnection.Any
scope: ServerScope
protocol: Promise<ServerProtocol>
url: string
client: ReturnType<typeof createSdkForServer>
api: CompatibleApi
currentApi: ServerApi
event: {
on: ServerEventEmitter["on"]
listen: ServerEventEmitter["listen"]
start: () => Promise<void> | undefined
}
createClient: (
opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">,
) => ReturnType<typeof createSdkForServer>
}
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
const platform = usePlatform()
const abort = new AbortController()
@@ -91,13 +198,15 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
})()
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
const eventSdk = createSdkForServer({
signal: abort.signal,
fetch: eventFetch,
server: server.http,
})
const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch)
const emitter = createGlobalEmitter<{
[key: string]: Event
[key: string]: ServerEvent
}>()
type Queued = QueuedServerEvent
@@ -142,21 +251,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
let run: Promise<void> | undefined
let started = false
let generation = 0
const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
const resetHeartbeat = () => {
lastEventAt = Date.now()
if (heartbeat) clearTimeout(heartbeat)
heartbeat = setTimeout(() => {
attempt?.abort()
}, HEARTBEAT_TIMEOUT_MS)
}
const clearHeartbeat = () => {
if (!heartbeat) return
clearTimeout(heartbeat)
heartbeat = undefined
}
const start = () => {
if (started) return run
@@ -168,35 +262,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
while (!abort.signal.aborted && started && generation === active) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (isStreamClosed(error, attempt?.signal)) return
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: server.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
const kind = await protocol
const events =
kind === "v1"
? (await eventSdk.global.event({ signal: attempt.signal })).stream
: eventApi.event.subscribe({ signal: attempt.signal })
let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) {
resetHeartbeat()
for await (const event of events) {
streamErrorLogged = false
if (event.payload.type !== "sync") {
const directory = event.directory ?? "global"
const payload = event.payload as Event
if (enqueueServerEvent(queue, { directory, payload })) schedule()
}
const legacy = "payload" in event
if (legacy && event.payload.type === "sync") continue
const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global")
const payload = legacy ? (event.payload as Event) : adaptServerEvent(event)
if (enqueueServerEvent(queue, { directory, payload })) schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
@@ -214,7 +297,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
clearHeartbeat()
}
if (abort.signal.aborted || !started || generation !== active) return
@@ -233,18 +315,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
started = false
generation++
attempt?.abort()
clearHeartbeat()
}
onMount(() => {
makeEventListener(window, "pagehide", stop)
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
makeEventListener(document, "visibilitychange", () => {
if (document.visibilityState !== "visible") return
if (!started) return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort()
})
})
onCleanup(() => {
@@ -258,12 +333,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
fetch: platform.fetch,
throwOnError: true,
})
const currentApi: ServerApi = createApiForServer({ server: server.http, fetch: platform.fetch })
const legacy = (directory?: string) =>
createSdkForServer({
server: server.http,
fetch: platform.fetch,
throwOnError: true,
directory,
})
const api = createCompatibleApi({ protocol, current: currentApi, legacy })
return {
server,
scope,
protocol,
url: server.http.url,
client: sdk,
api,
currentApi,
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
@@ -279,7 +366,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
}
type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
export type ServerSDK = ServerSDKBase & {
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
}
@@ -309,7 +395,7 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
})
type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }>
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
}
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
@@ -329,6 +415,12 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
scope: serverSDK.scope,
directory,
client,
api: createCompatibleApi({
protocol: serverSDK.protocol,
current: serverSDK.currentApi,
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
directory,
}),
event: emitter,
get url() {
return serverSDK.url
@@ -0,0 +1,113 @@
import { describe, expect, test } from "bun:test"
import { createApiForServer, createSdkForServer } from "./server"
import { createCompatibleApi } from "./server-compat"
function setup(protocol: "v1" | "v2" | Promise<"v1" | "v2">) {
const requests: Request[] = []
const fetcher = Object.assign(
async (input: string | URL | Request, init?: RequestInit) => {
const request = new Request(input, init)
requests.push(request)
if (request.method === "PATCH") {
return Response.json({
id: "ses_1",
slug: "ses_1",
projectID: "project",
directory: "/repo",
title: "Session",
version: "1",
time: { created: 1, updated: 1 },
})
}
if (request.method === "POST" && request.url.endsWith("/prompt_async"))
return new Response(undefined, { status: 204 })
if (request.method === "POST" && request.url.endsWith("/prompt")) {
return Response.json({
admittedSeq: 1,
id: "msg_1",
sessionID: "ses_1",
timeCreated: 1,
type: "user",
data: { text: "hello" },
delivery: "steer",
})
}
if (request.method === "GET") return Response.json([])
return new Response(undefined, { status: 204 })
},
{ preconnect: globalThis.fetch.preconnect },
)
const server = { url: "http://localhost:4096" }
const api = createCompatibleApi({
protocol: typeof protocol === "string" ? Promise.resolve(protocol) : protocol,
current: createApiForServer({ server, fetch: fetcher }),
legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }),
directory: "/repo",
})
return { api, requests }
}
describe("createCompatibleApi", () => {
test("routes V1 archive through the legacy session update", async () => {
const { api, requests } = setup("v1")
await api.session.archive({ sessionID: "ses_1", directory: "/repo" })
const url = new URL(requests[0]!.url)
expect(url.pathname).toBe("/session/ses_1")
expect(requests[0]!.headers.get("x-opencode-directory")).toBe("%2Frepo")
expect(requests[0]!.method).toBe("PATCH")
expect(await requests[0]!.json()).toMatchObject({ time: { archived: expect.any(Number) } })
})
test("converts current prompts to the V1 prompt contract", async () => {
const { api, requests } = setup("v1")
await api.session.prompt({
sessionID: "ses_1",
id: "msg_1",
text: "hello",
agent: "build",
model: { providerID: "provider", modelID: "model" },
})
expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/prompt_async")
expect(await requests[0]!.json()).toMatchObject({
messageID: "msg_1",
agent: "build",
model: { providerID: "provider", modelID: "model" },
parts: [{ type: "text", text: "hello" }],
})
})
test("keeps V2 session actions on the current API", async () => {
const { api, requests } = setup("v2")
await api.session.archive({ sessionID: "ses_1" })
expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive")
expect(requests[0]!.method).toBe("POST")
})
test("resolves protocol detection once across implementation methods", async () => {
let detections = 0
const resolved = Promise.resolve<"v1" | "v2">("v2")
const protocol = new Proxy(resolved, {
get(target, property) {
if (property !== "then") return Reflect.get(target, property, target)
detections++
return target.then.bind(target)
},
})
const { api } = setup(protocol)
await api.session.archive({ sessionID: "ses_1" })
await api.session.list()
expect(detections).toBe(1)
})
test("uses the global V1 session search endpoint", async () => {
const { api, requests } = setup("v1")
await api.session.list({ parentID: null, search: "session", limit: 50 })
expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session")
})
})
+495
View File
@@ -0,0 +1,495 @@
import type { ServerApi } from "./server"
import type { ServerProtocol } from "./server-protocol"
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2/client"
import type {
Project,
ProjectCurrent,
SessionApi,
SessionCommandInput,
SessionCommandOutput,
SessionCompactInput,
SessionCompactOutput,
SessionInfo,
SessionPromptInput,
SessionPromptOutput,
SessionShellInput,
SessionShellOutput,
} from "@opencode-ai/client/promise"
type LegacyClient = OpencodeClient
type LegacyFor = (directory?: string) => LegacyClient
type CompatibleSessionApi = Omit<
SessionApi,
"prompt" | "command" | "shell" | "compact" | "rename" | "archive" | "remove"
> & {
prompt: (input: SessionPromptInput & LegacyPrompt) => Promise<SessionPromptOutput>
command: (input: SessionCommandInput) => Promise<SessionCommandOutput>
shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput>
compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput>
rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]>
archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
}
export type CompatibleApi = Omit<ServerApi, "session"> & { readonly session: CompatibleSessionApi }
type LegacyPrompt = {
agent?: string
model?: { providerID: string; modelID: string }
variant?: string
}
type LegacyLocation = { directory?: string }
type CompatibleInput = {
protocol: Promise<ServerProtocol>
current: ServerApi
legacy: LegacyFor
directory?: string
}
function mime(uri: string) {
const match = /^data:([^;,]+)/.exec(uri)
return match?.[1] ?? "application/octet-stream"
}
function sessionInfo(session: Session): SessionInfo {
return {
id: session.id,
parentID: session.parentID,
projectID: session.projectID,
agent: session.agent,
model: session.model && {
id: session.model.id,
providerID: session.model.providerID,
variant: session.model.variant,
},
cost: session.cost ?? 0,
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: session.time,
title: session.title,
location: { directory: session.directory, workspaceID: session.workspaceID },
subpath: session.path,
revert: session.revert && {
messageID: session.revert.messageID,
partID: session.revert.partID,
snapshot: session.revert.snapshot,
},
}
}
export function createCompatibleApi(input: CompatibleInput): CompatibleApi {
const v1 = createV1Api(input)
return lazyApi(
input.protocol.then((protocol) => (protocol === "v1" ? v1 : input.current)),
input.current,
)
}
function lazyApi<T extends object>(implementation: Promise<T>, shape: T): T {
const cache = new Map<PropertyKey, unknown>()
return new Proxy(shape, {
get(target, property, receiver) {
const sample = Reflect.get(target, property, receiver)
if (typeof sample === "function") {
return (...args: unknown[]) =>
implementation.then((value) => {
const method = Reflect.get(value, property)
if (typeof method !== "function") throw new Error(`API method unavailable: ${String(property)}`)
return Reflect.apply(method, value, args)
})
}
if (sample === null || typeof sample !== "object") return sample
if (cache.has(property)) return cache.get(property)
const nested = lazyApi(
implementation.then((value) => {
const result = Reflect.get(value, property)
if (result === null || typeof result !== "object") {
throw new Error(`API namespace unavailable: ${String(property)}`)
}
return result
}),
sample,
)
cache.set(property, nested)
return nested
},
})
}
function createV1Api(input: CompatibleInput): CompatibleApi {
const directory = (location?: { directory?: string }) => location?.directory ?? input.directory
const legacy = (location?: { directory?: string }) => input.legacy(directory(location))
const located = <T>(data: T, value?: { directory?: string }) => ({
location: {
directory: directory(value) ?? "",
project: { id: "", directory: directory(value) ?? "" },
},
data,
})
return {
...input.current,
session: {
...input.current.session,
async list(
value?: Parameters<ServerApi["session"]["list"]>[0],
options?: Parameters<ServerApi["session"]["list"]>[1],
) {
if (!value?.directory && value?.search !== undefined) {
const result = await legacy().experimental.session.list(
{
roots: value.parentID === null ? true : undefined,
search: value.search,
limit: value.limit,
},
options,
)
return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
}
const result = await legacy({ directory: value?.directory }).session.list({
directory: value?.directory,
roots: value?.parentID === null ? true : undefined,
search: value?.search,
limit: value?.limit,
})
return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
},
async create(value?: Parameters<ServerApi["session"]["create"]>[0]) {
const result = await legacy(value?.location ?? undefined).session.create({
directory: directory(value?.location ?? undefined),
})
if (!result.data) throw new Error("Failed to create session")
return sessionInfo(result.data)
},
async get(value: Parameters<ServerApi["session"]["get"]>[0]) {
const result = await legacy().session.get(value)
if (!result.data) throw new Error(`Session not found: ${value.sessionID}`)
return sessionInfo(result.data)
},
async active() {
const result = await legacy().session.status()
return Object.fromEntries(
Object.entries(result.data ?? {}).flatMap(([sessionID, status]) =>
status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
),
)
},
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
},
async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
},
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
await legacy(value).session.delete(value)
},
async fork(value: Parameters<ServerApi["session"]["fork"]>[0]) {
const result = await legacy().session.fork(value)
if (!result.data) throw new Error("Failed to fork session")
return sessionInfo(result.data)
},
async interrupt(value: Parameters<ServerApi["session"]["interrupt"]>[0]) {
await legacy().session.abort(value)
},
async prompt(value: SessionPromptInput & LegacyPrompt) {
await legacy().session.promptAsync({
sessionID: value.sessionID,
messageID: value.id ?? undefined,
agent: value.agent,
model: value.model,
variant: value.variant,
parts: [
{ type: "text", text: value.text },
...(value.files ?? []).map((file) => ({
type: "file" as const,
mime: mime(file.uri),
url: file.uri,
filename: file.name,
})),
...(value.agents ?? []).map((agent) => ({
type: "agent" as const,
name: agent.name,
source: agent.mention
? { value: agent.mention.text, start: agent.mention.start, end: agent.mention.end }
: undefined,
})),
],
})
return {
admittedSeq: 0,
id: value.id ?? "",
sessionID: value.sessionID,
timeCreated: Date.now(),
type: "user",
data: { text: value.text },
delivery: value.delivery ?? "steer",
}
},
async command(value: SessionCommandInput) {
await legacy().session.command({
sessionID: value.sessionID,
messageID: value.id ?? undefined,
command: value.command,
arguments: value.arguments ?? "",
agent: value.agent ?? undefined,
model: value.model ? `${value.model.providerID}/${value.model.id}` : undefined,
variant: value.model?.variant,
parts: value.files?.map((file) => ({
type: "file" as const,
mime: mime(file.uri),
url: file.uri,
filename: file.name,
})),
})
return {
admittedSeq: 0,
id: value.id ?? "",
sessionID: value.sessionID,
timeCreated: Date.now(),
type: "user",
data: { text: `/${value.command} ${value.arguments ?? ""}`.trim() },
delivery: value.delivery ?? "steer",
}
},
async shell(value: SessionShellInput & LegacyPrompt) {
await legacy().session.shell({
sessionID: value.sessionID,
command: value.command,
agent: value.agent,
model: value.model,
})
},
compact: async (value: SessionCompactInput & { model?: LegacyPrompt["model"] }) => {
if (!value.model) throw new Error("A model is required to compact a V1 session")
await legacy().session.summarize({
sessionID: value.sessionID,
providerID: value.model.providerID,
modelID: value.model.modelID,
})
return {
admittedSeq: 0,
id: value.id ?? "",
sessionID: value.sessionID,
timeCreated: Date.now(),
type: "compaction",
}
},
revert: {
stage: async (value: Parameters<ServerApi["session"]["revert"]["stage"]>[0]) => {
await legacy().session.revert(value)
return { messageID: value.messageID }
},
clear: async (value: Parameters<ServerApi["session"]["revert"]["clear"]>[0]) => {
await legacy().session.unrevert(value)
},
commit: input.current.session.revert.commit,
},
},
project: {
...input.current.project,
async list() {
return ((await legacy().project.list()).data ?? []) as Project[]
},
async current(value?: Parameters<ServerApi["project"]["current"]>[0]) {
const result = await legacy(value?.location).project.current()
if (!result.data) throw new Error("Project not found")
return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
},
async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
const result = await legacy({ directory: project?.worktree }).project.update({
...value,
directory: project?.worktree,
})
if (!result.data) throw new Error(`Project not found: ${value.projectID}`)
return result.data as Project
},
async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) {
const result = await legacy(value.location).worktree.list()
return (result.data ?? []).map((item) => ({ directory: item }))
},
},
path: {
...input.current.path,
async get(value?: Parameters<ServerApi["path"]["get"]>[0]) {
const result = await legacy(value?.location).path.get()
if (!result.data) throw new Error("Path unavailable")
return result.data
},
},
vcs: {
...input.current.vcs,
async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
const result = await legacy(value?.location).vcs.get()
return located({ branch: result.data?.branch, defaultBranch: undefined }, value?.location)
},
async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
const result = await legacy(value?.location).vcs.status()
return located(result.data ?? [], value?.location)
},
async diff(value: Parameters<ServerApi["vcs"]["diff"]>[0]) {
const result = await legacy(value.location).vcs.diff({
mode: value.mode === "working" ? "git" : value.mode,
context: value.context,
})
return located(
(result.data ?? []).map((file) => ({
file: file.file,
patch: file.patch ?? "",
additions: file.additions,
deletions: file.deletions,
status: file.status ?? "modified",
})),
value.location,
)
},
},
file: {
...input.current.file,
async list(value?: Parameters<ServerApi["file"]["list"]>[0]) {
const result = await legacy(value?.location).file.list({ path: value?.path ?? "" })
return located(result.data ?? [], value?.location)
},
async find(value: Parameters<ServerApi["file"]["find"]>[0]) {
const result = await legacy(value.location).find.files({
query: value.query,
type: value.type,
limit: value.limit,
})
return located(
(result.data ?? []).map((path) => ({ path, type: value.type ?? "file" })),
value.location,
)
},
},
integration: {
...input.current.integration,
async get(value: Parameters<ServerApi["integration"]["get"]>[0]) {
const methods = ((await legacy(value.location).provider.auth()).data?.[value.integrationID] ?? []).map(
(method, index) =>
method.type === "api"
? { type: "key" as const, label: method.label }
: { type: "oauth" as const, id: String(index), label: method.label, prompts: method.prompts },
)
return located(
{
id: value.integrationID,
name: value.integrationID,
methods,
connections: [],
},
value.location,
)
},
connect: {
...input.current.integration.connect,
key: async (value: Parameters<ServerApi["integration"]["connect"]["key"]>[0]) => {
await legacy(value.location).auth.set({
providerID: value.integrationID,
auth: { type: "api", key: value.key },
})
},
},
oauth: {
...input.current.integration.oauth,
connect: async (value: Parameters<ServerApi["integration"]["oauth"]["connect"]>[0]) => {
const method = Number(value.methodID)
const result = await legacy(value.location).provider.oauth.authorize(
{ providerID: value.integrationID, method, inputs: value.inputs },
{ throwOnError: true },
)
if (!result.data) throw new Error("Failed to start OAuth authorization")
return located(
{
attemptID: `${value.integrationID}:${method}`,
url: result.data.url,
instructions: result.data.instructions,
mode: result.data.method,
time: { created: Date.now(), expires: Date.now() + 10 * 60 * 1000 },
},
value.location,
)
},
complete: async (value: Parameters<ServerApi["integration"]["oauth"]["complete"]>[0]) => {
const method = Number(value.attemptID.split(":").at(-1))
await legacy(value.location).provider.oauth.callback(
{ providerID: value.integrationID, method, code: value.code },
{ throwOnError: true },
)
},
status: async (value: Parameters<ServerApi["integration"]["oauth"]["status"]>[0]) => {
const method = Number(value.attemptID.split(":").at(-1))
await legacy(value.location).provider.oauth.callback(
{ providerID: value.integrationID, method },
{ throwOnError: true },
)
return located(
{ status: "complete" as const, time: { created: Date.now(), expires: Date.now() } },
value.location,
)
},
},
},
pty: {
...input.current.pty,
async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
},
async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) {
return located((await legacy(value?.location).pty.list()).data ?? [], value?.location)
},
async create(value?: Parameters<ServerApi["pty"]["create"]>[0]) {
const result = await legacy(value?.location).pty.create({
command: value?.command,
args: value?.args ? [...value.args] : undefined,
cwd: value?.cwd,
title: value?.title,
env: value?.env,
})
if (!result.data) throw new Error("Failed to create terminal")
return located(result.data, value?.location)
},
async get(value: Parameters<ServerApi["pty"]["get"]>[0]) {
const result = await legacy(value.location).pty.get({ ptyID: value.ptyID })
if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
return located(result.data, value.location)
},
async update(value: Parameters<ServerApi["pty"]["update"]>[0]) {
const result = await legacy(value.location).pty.update({
ptyID: value.ptyID,
title: value.title,
size: value.size,
})
if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
return located(result.data, value.location)
},
async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) {
await legacy(value.location).pty.remove({ ptyID: value.ptyID })
},
async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
return located(result.data, value.location)
},
},
permission: {
...input.current.permission,
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0]) {
await legacy().permission.respond({
sessionID: value.sessionID,
permissionID: value.requestID,
response: value.reply,
})
},
},
question: {
...input.current.question,
async reply(value: Parameters<ServerApi["question"]["reply"]>[0]) {
await legacy().question.reply({
requestID: value.requestID,
answers: value.answers.map((answer) => [...answer]),
})
},
async reject(value: Parameters<ServerApi["question"]["reject"]>[0]) {
await legacy().question.reject({ requestID: value.requestID })
},
},
}
}
+34 -4
View File
@@ -14,15 +14,45 @@ function abortFromInput(input: RequestInfo | URL, init?: RequestInit) {
describe("checkServerHealth", () => {
test("returns healthy response with version", async () => {
const fetch = (async () =>
new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
let request: URL | undefined
const fetch = (async (input: RequestInfo | URL) => {
request = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof globalThis.fetch
})
}) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch)
expect(result).toEqual({ healthy: true, version: "1.2.3" })
expect(request?.pathname).toBe("/api/health")
})
test("falls back to the V1 health endpoint", async () => {
const paths: string[] = []
const fetch = (async (input: RequestInfo | URL) => {
const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
paths.push(url.pathname)
if (url.pathname === "/api/health") return new Response(undefined, { status: 404 })
return Response.json({ healthy: true, version: "1.18.4" })
}) as unknown as typeof globalThis.fetch
expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" })
expect(paths).toEqual(["/api/health", "/global/health"])
})
test("falls back when the current health response is malformed", async () => {
const paths: string[] = []
const fetch = (async (input: RequestInfo | URL) => {
const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
paths.push(url.pathname)
if (url.pathname === "/api/health") return Response.json({})
return Response.json({ healthy: true, version: "1.18.4" })
}) as unknown as typeof globalThis.fetch
expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" })
expect(paths).toEqual(["/api/health", "/global/health"])
})
test("allows slow servers thirty seconds by default", async () => {
@@ -142,7 +172,7 @@ describe("checkServerHealth", () => {
retryDelayMs: 1,
})
expect(count).toBe(3)
expect(count).toBe(6)
expect(result).toEqual({ healthy: false })
})
})
+23 -5
View File
@@ -1,6 +1,7 @@
import { usePlatform } from "@/context/platform"
import { ServerConnection } from "@/context/server"
import { createSdkForServer } from "./server"
import { authTokenFromCredentials, createSdkForServer } from "./server"
import { ClientError, OpenCode } from "@opencode-ai/client"
import { Accessor, createEffect, onCleanup } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
@@ -61,6 +62,7 @@ function wait(ms: number, signal?: AbortSignal) {
function retryable(error: unknown, signal?: AbortSignal) {
if (signal?.aborted) return false
if (error instanceof ClientError) return error.reason === "Transport"
if (!(error instanceof Error)) return false
if (error.name === "AbortError" || error.name === "TimeoutError") return false
if (error instanceof TypeError) return true
@@ -82,15 +84,31 @@ export async function checkServerHealth(
.then(() => attempt(count + 1))
.catch(() => ({ healthy: false }))
}
const attempt = (count: number): Promise<ServerHealth> =>
createSdkForServer({
server,
const attempt = async (count: number): Promise<ServerHealth> => {
const current = await OpenCode.make({
baseUrl: server.url,
fetch,
signal,
headers: server.password
? {
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
}
: undefined,
})
.health.get({ signal })
.then((x) =>
typeof x.healthy === "boolean"
? { data: { healthy: x.healthy, version: x.version } }
: { error: new Error("Invalid health response") },
)
.catch((error) => ({ error }))
if ("data" in current && current.data) return current.data
if (signal?.aborted) return { healthy: false }
return createSdkForServer({ server, fetch, signal })
.global.health()
.then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version }))
.catch((error) => next(count, error))
}
return attempt(0).finally(() => timeout?.clear?.())
}
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test"
import { detectServerProtocol } from "./server-protocol"
const server = { url: "http://localhost:4096" }
const json = (value: unknown, status = 200) =>
new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } })
const mockFetch = (run: (input: string | URL | Request) => Promise<Response>) =>
Object.assign(run, { preconnect: globalThis.fetch.preconnect })
describe("detectServerProtocol", () => {
test("prefers the legacy health endpoint when both API generations exist", async () => {
const fetcher = mockFetch((input) => {
const path = new URL(input instanceof Request ? input.url : input).pathname
if (path === "/global/health") return Promise.resolve(json({ healthy: true, version: "1.18.4" }))
return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 }))
})
expect(await detectServerProtocol(server, fetcher)).toBe("v1")
})
test("recognizes V2 health by its process identifier", async () => {
const fetcher = mockFetch((input) => {
const path = new URL(input instanceof Request ? input.url : input).pathname
if (path === "/global/health") return Promise.resolve(json({}, 404))
return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 }))
})
expect(await detectServerProtocol(server, fetcher)).toBe("v2")
})
test("recognizes the transitional V1 API health response", async () => {
const fetcher = mockFetch((input) => {
const path = new URL(input instanceof Request ? input.url : input).pathname
if (path === "/global/health") return Promise.resolve(json({}, 404))
return Promise.resolve(json({ healthy: true }))
})
expect(await detectServerProtocol(server, fetcher)).toBe("v1")
})
})
+35
View File
@@ -0,0 +1,35 @@
import type { ServerConnection } from "@/context/server"
import { authTokenFromCredentials } from "./server"
export type ServerProtocol = "v1" | "v2"
function headers(server: ServerConnection.HttpBase) {
if (!server.password) return
return {
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
}
}
async function probe(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch, path: string) {
const response = await fetch(new URL(path, server.url), {
headers: headers(server),
signal: AbortSignal.timeout(5_000),
})
if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return
const value: unknown = await response.json()
if (!value || typeof value !== "object") return
return value
}
export async function detectServerProtocol(
server: ServerConnection.HttpBase,
fetch: typeof globalThis.fetch,
): Promise<ServerProtocol> {
const legacy = await probe(server, fetch, "/global/health").catch(() => undefined)
if (legacy && "healthy" in legacy && legacy.healthy === true) return "v1"
const current = await probe(server, fetch, "/api/health").catch(() => undefined)
if (current && "pid" in current && typeof current.pid === "number") return "v2"
if (current && "healthy" in current && current.healthy === true) return "v1"
return "v2"
}
+21
View File
@@ -1,4 +1,5 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import type { ServerConnection } from "@/context/server"
import { decode64 } from "@/utils/base64"
@@ -39,3 +40,23 @@ export function createSdkForServer({
baseUrl: server.url,
})
}
export function createApiForServer(input: {
server: ServerConnection.HttpBase
fetch?: typeof globalThis.fetch
}): OpenCodeClient {
return OpenCode.make({
baseUrl: input.server.url,
fetch: input.fetch,
headers: input.server.password
? {
Authorization: `Basic ${authTokenFromCredentials({
username: input.server.username,
password: input.server.password,
})}`,
}
: undefined,
})
}
export type ServerApi = OpenCodeClient
Binary file not shown.
+9 -14
View File
@@ -28,7 +28,7 @@ export type TurnControl = {
type ToolState = {
readonly name: string
input: ToolInput
structured: Record<string, unknown>
metadata: Record<string, unknown>
content: ToolContent
}
@@ -38,7 +38,7 @@ export type TurnStart =
| { readonly type: "compaction"; readonly id: string }
function emptyToolState(): ToolState {
return { name: "tool", input: {}, structured: {}, content: [] }
return { name: "tool", input: {}, metadata: {}, content: [] }
}
export async function streamTurn(input: {
@@ -120,7 +120,7 @@ export async function streamTurn(input: {
}
if (event.type === "session.tool.input.started") {
assistantMessageID = event.data.assistantMessageID
tools.set(event.data.callID, { name: event.data.name, input: {}, structured: {}, content: [] })
tools.set(event.data.callID, { name: event.data.name, input: {}, metadata: {}, content: [] })
await update({
sessionUpdate: "tool_call",
...pendingToolCall({
@@ -151,15 +151,13 @@ export async function streamTurn(input: {
if (event.type === "session.tool.progress") {
const current = tools.get(event.data.callID)
if (!current) continue
current.structured = event.data.structured
current.content = event.data.content
current.metadata = event.data.metadata
await update({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.callID,
toolName: current.name,
state: { input: current.input },
content: current.content,
cwd: input.cwd,
}),
})
@@ -175,7 +173,7 @@ export async function streamTurn(input: {
cwd: input.cwd,
toolName: current.name,
toolInput: current.input,
structured: event.data.structured,
metadata: event.data.metadata ?? {},
}).catch(() => {})
await update({
sessionUpdate: "tool_call_update",
@@ -183,9 +181,8 @@ export async function streamTurn(input: {
toolCallId: event.data.callID,
toolName: current.name,
input: current.input,
structured: event.data.structured,
metadata: event.data.metadata,
content: event.data.content,
result: event.data.result,
}),
})
continue
@@ -199,7 +196,7 @@ export async function streamTurn(input: {
toolCallId: event.data.callID,
toolName: current.name,
input: current.input,
structured: event.data.metadata ?? current.structured,
metadata: event.data.metadata ?? current.metadata,
content: event.data.content ?? current.content,
error: event.data.error.message,
cwd: input.cwd,
@@ -342,9 +339,8 @@ async function replayMessage(
toolCallId: part.id,
toolName: part.name,
input: part.state.input,
structured: part.state.structured,
metadata: part.state.metadata,
content: part.state.content,
result: part.state.result,
}),
},
})
@@ -358,7 +354,6 @@ async function replayMessage(
toolCallId: part.id,
toolName: part.name,
state: { input: part.state.input },
content: part.state.content,
cwd,
}),
},
@@ -373,7 +368,7 @@ async function replayMessage(
toolCallId: part.id,
toolName: part.name,
input: part.state.input,
structured: part.state.structured,
metadata: part.state.metadata,
content: part.state.content,
error: part.state.error.message,
cwd,
+3 -3
View File
@@ -58,11 +58,11 @@ export async function syncEditedFiles(input: {
readonly cwd: string
readonly toolName: string
readonly toolInput: ToolInput
readonly structured: Readonly<Record<string, unknown>>
readonly metadata: Readonly<Record<string, unknown>>
}) {
if (!input.writeTextFile || !input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return
const files = Array.isArray(input.structured.files)
? input.structured.files.flatMap((file): string[] => {
const files = Array.isArray(input.metadata.files)
? input.metadata.files.flatMap((file): string[] => {
if (!file || typeof file !== "object") return []
const path = Reflect.get(file, "file")
return typeof path === "string" ? [path] : []
+13 -24
View File
@@ -1,5 +1,6 @@
import { isAbsolute, resolve } from "node:path"
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
import { readDisplayText } from "@opencode-ai/tui/mini/tool"
export type ToolInput = Record<string, unknown>
export type ToolContent = ReadonlyArray<
@@ -100,11 +101,12 @@ export function completedToolUpdate(input: {
readonly toolName: string
readonly input: ToolInput
readonly content: ToolContent
readonly structured: Readonly<Record<string, unknown>>
readonly result?: unknown
readonly metadata?: Readonly<Record<string, unknown>>
}): ToolCallUpdate {
const normalized = toolContent(input.content)
const read = input.toolName.toLocaleLowerCase() === "read" ? readDisplayText(input.structured) : undefined
// Read's model content is a JSON page envelope; show the clean text instead.
const firstText = input.content.find((part) => part.type === "text")
const read = input.toolName.toLocaleLowerCase() === "read" && firstText ? readDisplayText(firstText.text) : undefined
const images = normalized.filter((part) => part.type === "content" && part.content.type === "image")
const primary =
read === undefined
@@ -128,8 +130,7 @@ export function completedToolUpdate(input: {
status: "completed",
content: [...primary, ...diff, ...images],
rawOutput: {
structured: input.structured,
...(input.result === undefined ? {} : { result: input.result }),
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
},
}
}
@@ -138,8 +139,8 @@ export function errorToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly input: ToolInput
readonly content: ToolContent
readonly structured: Readonly<Record<string, unknown>>
readonly content?: ToolContent
readonly metadata?: Readonly<Record<string, unknown>>
readonly error: string
readonly cwd?: string
}): ToolCallUpdate {
@@ -150,8 +151,11 @@ export function errorToolUpdate(input: {
title: toolTitle(input.toolName, input.input, undefined),
locations: toLocations(input.toolName, input.input, input.cwd),
rawInput: rawInput(input.toolName, input.input, input.cwd),
content: [...toolContent(input.content), { type: "content", content: { type: "text", text: input.error } }],
rawOutput: { structured: input.structured, error: input.error },
content: [...toolContent(input.content ?? []), { type: "content", content: { type: "text", text: input.error } }],
rawOutput: {
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
error: input.error,
},
}
}
@@ -164,21 +168,6 @@ function toolContent(content: ToolContent): ToolCallContent[] {
})
}
function readDisplayText(structured: Readonly<Record<string, unknown>>) {
if (typeof structured.content === "string") {
if (structured.type === "text-page" || structured.encoding === "utf8") return structured.content
}
if (!Array.isArray(structured.entries)) return undefined
return structured.entries
.flatMap((entry): string[] => {
if (typeof entry === "string") return [entry]
if (!entry || typeof entry !== "object") return []
const path = Reflect.get(entry, "path")
return typeof path === "string" ? [path] : []
})
.join("\n")
}
function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) {
if (isShell(toolName)) return stringValue(input.command) ?? stringValue(input.cmd) ?? fallback ?? toolName
return fallback || toolName
@@ -10,6 +10,7 @@ import {
Reference,
Skill,
} from "@opencode-ai/plugin/v2"
import { Tool } from "@opencode-ai/plugin/v2/tool"
const key = Symbol.for("opencode.plugin.v2.promise")
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
@@ -23,4 +24,5 @@ const key = Symbol.for("opencode.plugin.v2.promise")
Provider,
Reference,
Skill,
Tool,
}
+25 -23
View File
@@ -10,7 +10,7 @@ import type {
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { EOL } from "node:os"
import { readFile } from "node:fs/promises"
import { toolOutputText, type MiniToolPart } from "@opencode-ai/tui/mini/tool"
import { nonEmptyToolContent, toolOutputText, type MiniToolPart } from "@opencode-ai/tui/mini/tool"
import { UI } from "./ui"
type Model = {
@@ -55,7 +55,7 @@ type ToolState = StartedPart & {
raw?: string
provider?: unknown
providerState?: SessionMessageAssistantTool["providerState"]
structured: Record<string, JsonValue>
metadata: Record<string, JsonValue>
content: LLMToolContent[]
}
@@ -306,7 +306,7 @@ export async function runNonInteractivePrompt(input: Input) {
assistantMessageID: event.data.assistantMessageID,
tool: event.data.name,
input: {},
structured: {},
metadata: {},
content: [],
})
continue
@@ -334,7 +334,7 @@ export async function runNonInteractivePrompt(input: Input) {
raw: current?.raw,
provider: { executed: event.data.executed, state: event.data.state },
providerState: event.data.state,
structured: {},
metadata: {},
content: [],
})
continue
@@ -342,8 +342,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.tool.progress") {
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
if (current) {
current.structured = event.data.structured
current.content = event.data.content
current.metadata = event.data.metadata
}
continue
}
@@ -360,9 +359,8 @@ export async function runNonInteractivePrompt(input: Input) {
state: {
status: "completed",
input: current.input,
structured: event.data.structured,
metadata: event.data.metadata,
content: event.data.content,
result: event.data.result,
},
time: { created: current.timestamp, ran: current.timestamp, completed: time },
}
@@ -379,9 +377,8 @@ export async function runNonInteractivePrompt(input: Input) {
output: toolOutputText(current.tool, event.data.content),
title: current.tool,
metadata: {
structured: event.data.structured,
metadata: event.data.metadata,
content: event.data.content,
result: event.data.result,
providerCall: current.provider,
providerResult: { executed: event.data.executed, state: event.data.resultState },
rawInput: current.raw,
@@ -398,8 +395,8 @@ export async function runNonInteractivePrompt(input: Input) {
const key = toolKey(event.data.assistantMessageID, event.data.callID)
const current = tools.get(key) ?? fallbackTool(event)
const error = event.data.error.message
const structured = event.data.metadata ?? current.structured
const content = event.data.content ?? current.content
const metadata = event.data.metadata ?? current.metadata
const content = event.data.content ?? nonEmptyToolContent(current.content)
const tool: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
@@ -410,10 +407,9 @@ export async function runNonInteractivePrompt(input: Input) {
state: {
status: "error",
input: current.input,
structured,
metadata,
content,
error: event.data.error,
result: event.data.result,
},
time: { created: current.timestamp, ran: current.timestamp, completed: time },
}
@@ -429,7 +425,6 @@ export async function runNonInteractivePrompt(input: Input) {
input: current.input,
error,
metadata: {
result: event.data.result,
providerCall: current.provider,
providerResult: { executed: event.data.executed, state: event.data.resultState },
rawInput: current.raw,
@@ -441,15 +436,14 @@ export async function runNonInteractivePrompt(input: Input) {
renderedTools.add(key)
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue
if (!emit("tool_use", time, { part })) {
if (toolOutputText(current.tool, content).trim())
if (content && toolOutputText(current.tool, content).trim())
await input.renderTool({
...tool,
state: {
status: "completed",
input: current.input,
structured,
metadata,
content,
result: event.data.result,
},
})
await input.renderToolError(tool)
@@ -597,14 +591,14 @@ export async function runNonInteractivePrompt(input: Input) {
input: item.state.input,
output: toolOutputText(item.name, item.state.content),
title: item.name,
metadata: { structured: item.state.structured, content: item.state.content, result: item.state.result },
metadata: { metadata: item.state.metadata, content: item.state.content },
time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp },
}
: {
status: "error",
input: item.state.input,
error: item.state.error.message,
metadata: { structured: item.state.structured, content: item.state.content, result: item.state.result },
metadata: { metadata: item.state.metadata, content: item.state.content },
time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp },
},
}
@@ -614,8 +608,16 @@ export async function runNonInteractivePrompt(input: Input) {
await input.renderTool(item)
continue
}
if (toolOutputText(item.name, item.state.content).trim()) {
await input.renderTool({ ...item, state: { ...item.state, status: "completed" } })
if (item.state.content && toolOutputText(item.name, item.state.content).trim()) {
await input.renderTool({
...item,
state: {
status: "completed",
input: item.state.input,
metadata: item.state.metadata,
content: item.state.content,
},
})
}
await input.renderToolError(item)
UI.error(item.state.error.message)
@@ -792,7 +794,7 @@ function fallbackTool(event: {
assistantMessageID: event.data.assistantMessageID,
tool: "tool",
input: {},
structured: {},
metadata: {},
content: [],
}
}
+11 -18
View File
@@ -218,8 +218,7 @@ describe("acp event behavior", () => {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
structured: { phase: 1 },
content: [{ type: "text", text: "working" }],
metadata: { phase: 1 },
}),
)
send(
@@ -227,9 +226,8 @@ describe("acp event behavior", () => {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
structured: { exit: 0 },
metadata: { exit: 0 },
content: [{ type: "text", text: "done" }],
result: { code: 0 },
executed: true,
}),
)
@@ -255,8 +253,7 @@ describe("acp event behavior", () => {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
structured: { bytes: 0 },
content: [{ type: "text", text: "opening" }],
metadata: { bytes: 0 },
}),
)
send(
@@ -313,12 +310,10 @@ describe("acp event behavior", () => {
locations: [{ path: resolve("/workspace", "sub") }],
rawInput: { command: "printf done", workdir: "sub" },
})
expect(updates[2]?.update).toMatchObject({
content: [{ type: "content", content: { type: "text", text: "working" } }],
})
expect(updates[2]?.update).not.toHaveProperty("content")
expect(updates[3]?.update).toMatchObject({
content: [{ type: "content", content: { type: "text", text: "done" } }],
rawOutput: { structured: { exit: 0 }, result: { code: 0 } },
rawOutput: { metadata: { exit: 0 } },
})
expect(updates[7]?.update).toMatchObject({
kind: "read",
@@ -327,7 +322,7 @@ describe("acp event behavior", () => {
{ type: "content", content: { type: "text", text: "opening" } },
{ type: "content", content: { type: "text", text: "not found" } },
],
rawOutput: { structured: { bytes: 0 }, error: "not found" },
rawOutput: { metadata: { bytes: 0 }, error: "not found" },
})
expect(response.stopReason).toBe("end_turn")
} finally {
@@ -379,7 +374,7 @@ describe("acp event behavior", () => {
{ type: "content", content: { type: "text", text: "done" } },
{ type: "content", content: { type: "image", mimeType: "image/png", data: "AAAA" } },
],
rawOutput: { structured: { exit: 0 }, result: { code: 0 } },
rawOutput: { metadata: { exit: 0 } },
})
expect(updates[8]?.update).toMatchObject({
toolCallId: "call_running",
@@ -618,12 +613,11 @@ function replayFixtureMessages(): SessionMessageInfo[] {
state: {
status: "completed",
input: { command: "printf done" },
structured: { exit: 0 },
metadata: { exit: 0 },
content: [
{ type: "text", text: "done" },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" },
],
result: { code: 0 },
},
},
{
@@ -634,8 +628,7 @@ function replayFixtureMessages(): SessionMessageInfo[] {
state: {
status: "running",
input: { command: "pwd" },
structured: {},
content: [{ type: "text", text: "/workspace" }],
metadata: {},
},
},
{
@@ -646,7 +639,7 @@ function replayFixtureMessages(): SessionMessageInfo[] {
state: {
status: "error",
input: { filePath: "/workspace/missing.ts" },
structured: { bytes: 0 },
metadata: { bytes: 0 },
content: [{ type: "text", text: "partial" }],
error: { type: "tool.error", message: "failed hard" },
},
@@ -679,7 +672,7 @@ function replayToolMessage(id: string) {
state: {
status: "completed",
input: { command: "printf done" },
structured: { exit: 0 },
metadata: { exit: 0 },
content: [{ type: "text", text: "done" }],
},
},
@@ -28,7 +28,7 @@ describe("acp permission behavior", () => {
cwd: "/workspace",
toolName: "edit",
toolInput: { filePath: "/workspace/file.ts" },
structured: {},
metadata: {},
})
expect(writes).toEqual([])
@@ -193,7 +193,7 @@ describe("acp permission behavior", () => {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
callID: "call_edit",
structured: { files: [{ file: "file.ts" }], replacements: 1 },
metadata: { files: [{ file: "file.ts" }], replacements: 1 },
content: [{ type: "text", text: "edited" }],
executed: true,
}),
@@ -286,7 +286,7 @@ describe("acp permission behavior", () => {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
callID: "call_patch",
structured: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
metadata: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
content: [{ type: "text", text: "patched" }],
executed: true,
}),
+33 -30
View File
@@ -63,7 +63,7 @@ describe("acp tools", () => {
{ type: "file", mime: "image/png", name: "image.png", uri: `data:image/png;base64,${image}` },
{ type: "file", mime: "text/plain", name: "note.txt", uri: "data:text/plain;base64,bm90ZQ==" },
],
structured: {},
metadata: {},
}).content,
).toEqual([
{
@@ -93,7 +93,7 @@ describe("acp tools", () => {
content: "created",
},
content: [{ type: "text", text: "wrote /tmp/file.ts" }],
structured: {},
metadata: {},
}).content,
).toEqual([
{
@@ -103,20 +103,22 @@ describe("acp tools", () => {
])
})
test("uses clean structured read content instead of model-facing formatting", () => {
test("unwraps read's JSON page envelope instead of showing model-facing formatting", () => {
expect(
completedToolUpdate({
toolCallId: "tool-read",
toolName: "read",
input: { path: "/tmp/file.ts" },
content: [{ type: "text", text: "<content>1: first\n2: second</content>" }],
structured: {
type: "text-page",
content: "first\nsecond",
mime: "text/plain",
offset: 1,
truncated: false,
},
content: [
{
type: "text",
text: JSON.stringify(
{ type: "text-page", content: "first\nsecond", mime: "text/plain", offset: 1, truncated: false },
null,
2,
),
},
],
}).content,
).toEqual([{ type: "content", content: { type: "text", text: "first\nsecond" } }])
@@ -125,13 +127,17 @@ describe("acp tools", () => {
toolCallId: "tool-list",
toolName: "read",
input: { path: "/tmp" },
content: [],
structured: {
entries: [
{ path: "a.ts", type: "file" },
{ path: "src", type: "directory" },
],
},
content: [
{
type: "text",
text: JSON.stringify({
entries: [
{ path: "a.ts", type: "file" },
{ path: "src", type: "directory" },
],
}),
},
],
}).content,
).toEqual([{ type: "content", content: { type: "text", text: "a.ts\nsrc" } }])
})
@@ -171,7 +177,7 @@ describe("acp tools", () => {
newString: "after",
},
content: [{ type: "text", text: "Edit applied successfully." }],
structured: { output: "Edit applied successfully." },
metadata: { output: "Edit applied successfully." },
}),
).toEqual({
toolCallId: "tool-1",
@@ -189,7 +195,7 @@ describe("acp tools", () => {
},
],
rawOutput: {
structured: { output: "Edit applied successfully." },
metadata: { output: "Edit applied successfully." },
},
})
})
@@ -209,7 +215,7 @@ describe("acp tools", () => {
})
})
test("builds completed raw output with structured data and optional result", () => {
test("builds completed raw output with optional metadata", () => {
const attachments = [
{
type: "file",
@@ -225,12 +231,10 @@ describe("acp tools", () => {
toolName: "read",
input: {},
content: [],
structured: { output: "done", metadata: { exit: 0 }, attachments },
result: "done",
metadata: { output: "done", metadata: { exit: 0 }, attachments },
}).rawOutput,
).toEqual({
structured: { output: "done", metadata: { exit: 0 }, attachments },
result: "done",
metadata: { output: "done", metadata: { exit: 0 }, attachments },
})
expect(
@@ -239,9 +243,8 @@ describe("acp tools", () => {
toolName: "read",
input: {},
content: [],
structured: { output: "done" },
}).rawOutput,
).toEqual({ structured: { output: "done" } })
).toEqual({})
})
test("extracts image attachments only from data URLs", () => {
@@ -255,7 +258,7 @@ describe("acp tools", () => {
{ type: "file", mime: "image/png", uri: "https://example.com/image.png" },
{ type: "file", mime: "text/plain", uri: "data:text/plain;base64,BBBB" },
],
structured: {},
metadata: {},
}).content,
).toEqual([
{
@@ -272,7 +275,7 @@ describe("acp tools", () => {
toolName: "read",
input: { filePath: "/tmp/a" },
content: [{ type: "text", text: "partial output" }],
structured: { path: "/tmp/a" },
metadata: { path: "/tmp/a" },
error: "failed",
}),
).toEqual({
@@ -286,7 +289,7 @@ describe("acp tools", () => {
{ type: "content", content: { type: "text", text: "partial output" } },
{ type: "content", content: { type: "text", text: "failed" } },
],
rawOutput: { structured: { path: "/tmp/a" }, error: "failed" },
rawOutput: { metadata: { path: "/tmp/a" }, error: "failed" },
})
})
})
+6 -1
View File
@@ -23,7 +23,12 @@ describe("CLI frontend import boundaries", () => {
expect(Object.keys(run).sort()).toEqual(["runNonInteractive", "runV1Bridge"])
expect(Object.keys(mini).sort()).toEqual(["runMiniFrontend"])
expect(Object.keys(tool).sort()).toEqual(["toolInlineInfo", "toolOutputText"])
expect(Object.keys(tool).sort()).toEqual([
"nonEmptyToolContent",
"readDisplayText",
"toolInlineInfo",
"toolOutputText",
])
expect(Object.keys(cli.exports).filter((key) => key === "./mini" || key.startsWith("./mini/"))).toEqual([])
})
+11 -14
View File
@@ -134,15 +134,14 @@ function failedTool(inputID: string): V2Event[] {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
structured: { checkpoint: 1 },
content: [{ type: "text", text: "partial output" }],
metadata: { checkpoint: 1 },
},
},
{
id: "evt_failed_tool_terminal",
created: 4,
type: "session.tool.failed",
durable: { aggregateID: "ses_1", seq: 4, version: 1 },
durable: { aggregateID: "ses_1", seq: 4, version: 2 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
@@ -190,12 +189,12 @@ function successfulGrep(inputID: string): V2Event[] {
id: "evt_grep_success",
created: 3,
type: "session.tool.success",
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
durable: { aggregateID: "ses_1", seq: 3, version: 2 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
callID: "call_grep",
structured: { matches: 2 },
metadata: { matches: 2 },
content: [{ type: "text", text }],
executed: false,
},
@@ -258,9 +257,7 @@ async function run(input: {
spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
spyOn(sdk.message, "list").mockImplementation(() =>
ok({
data: input.messages?.(promptID) ?? [
{ id: promptID, type: "user", text: "hello", time: { created: 1 } },
],
data: input.messages?.(promptID) ?? [{ id: promptID, type: "user", text: "hello", time: { created: 1 } }],
cursor: {},
}),
)
@@ -316,7 +313,7 @@ afterEach(() => {
})
describe("runNonInteractivePrompt", () => {
test("keeps formatted tool output and compact structured metadata in JSON", async () => {
test("keeps formatted tool output and compact tool metadata in JSON", async () => {
const output = await capture({ format: "json", turn: successfulGrep })
const events = output.stdout
.split("\n")
@@ -332,13 +329,13 @@ describe("runNonInteractivePrompt", () => {
status: "completed",
output: expect.stringContaining("Found 2 matches"),
metadata: {
structured: { matches: 2 },
metadata: { matches: 2 },
content: [{ type: "text", text: expect.stringContaining("/src/a.ts") }],
},
},
},
})
expect(events[0].part.state.metadata.structured).toEqual({ matches: 2 })
expect(events[0].part.state.metadata.metadata).toEqual({ matches: 2 })
expect(events[0].part.state.metadata.result).toBeUndefined()
})
@@ -534,7 +531,7 @@ describe("runNonInteractivePrompt", () => {
id: "call_failed_tool",
state: {
status: "completed",
structured: { checkpoint: 1 },
metadata: { checkpoint: 1 },
content: [{ type: "text", text: "partial output" }],
},
},
@@ -544,7 +541,7 @@ describe("runNonInteractivePrompt", () => {
id: "call_failed_tool",
state: {
status: "error",
structured: { checkpoint: 1 },
metadata: { checkpoint: 1 },
content: [{ type: "text", text: "partial output" }],
error: { message: "tool failed" },
},
@@ -574,7 +571,7 @@ describe("runNonInteractivePrompt", () => {
},
})
expect(events[0].part.state.output).toBeUndefined()
expect(events[0].part.state.metadata.structured).toBeUndefined()
expect(events[0].part.state.metadata.metadata).toBeUndefined()
expect(events[0].part.state.metadata.content).toBeUndefined()
expect(output.stderr).toBe("")
})
+8 -4
View File
@@ -75,6 +75,10 @@ export const define = sdk.Plugin.define`
const effectPluginModule = promisePluginModule
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
.replace("Promise plugin", "Effect plugin")
const promiseToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
export const Tool = sdk.Tool
export const make = sdk.Tool.make`
const effectToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.effect")]
if (!sdk) throw new Error("OpenCode Effect plugin SDK is unavailable")
export const Tool = sdk.Tool
@@ -83,10 +87,8 @@ export const RegistrationError = sdk.Tool.RegistrationError
export const make = sdk.Tool.make
export const validateName = sdk.Tool.validateName
export const registrationEntries = sdk.Tool.registrationEntries
export const withPermission = sdk.Tool.withPermission
export const permission = sdk.Tool.permission
export const definition = sdk.Tool.definition
export const settle = sdk.Tool.settle`
export const validateNamespace = sdk.Tool.validateNamespace
export const toLLMDefinition = sdk.Tool.toLLMDefinition`
return `#!/usr/bin/env -S node ${nodeExecArgv.join(" ")}
import __cjs_mod__ from "node:module"
import { chmodSync as __ocChmod, existsSync as __ocExists, lstatSync as __ocLstat, mkdirSync as __ocMkdir, renameSync as __ocRename, rmSync as __ocRm, writeFileSync as __ocWrite } from "node:fs"
@@ -100,6 +102,7 @@ const require = __cjs_mod__.createRequire(import.meta.url)
const __ocPluginModules = ${JSON.stringify({
"@opencode-ai/plugin/v2": "opencode:plugin-v2",
"@opencode-ai/plugin/v2/plugin": "opencode:plugin-v2-plugin",
"@opencode-ai/plugin/v2/tool": "opencode:plugin-v2-tool",
"@opencode-ai/plugin/v2/effect": "opencode:plugin-v2-effect",
"@opencode-ai/plugin/v2/effect/plugin": "opencode:plugin-v2-effect-plugin",
"@opencode-ai/plugin/v2/effect/tool": "opencode:plugin-v2-effect-tool",
@@ -107,6 +110,7 @@ const __ocPluginModules = ${JSON.stringify({
const __ocPluginSources = ${JSON.stringify({
"opencode:plugin-v2": promiseModule,
"opencode:plugin-v2-plugin": promisePluginModule,
"opencode:plugin-v2-tool": promiseToolModule,
"opencode:plugin-v2-effect": effectModule,
"opencode:plugin-v2-effect-plugin": effectPluginModule,
"opencode:plugin-v2-effect-tool": effectToolModule,
+24 -35
View File
@@ -104,6 +104,12 @@ export type SessionMessageProviderState = { [x: string]: JsonValue }
export type SessionMessageToolStateStreaming = { status: "streaming"; input: string }
export type SessionMessageToolStateRunning = {
status: "running"
input: { [x: string]: JsonValue }
metadata: { [x: string]: JsonValue }
}
export type ToolTextContent = { type: "text"; text: string }
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string }
@@ -918,6 +924,15 @@ export type SessionToolInputDelta = {
data: { sessionID: string; assistantMessageID: string; callID: string; delta: string }
}
export type SessionToolProgress = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.tool.progress"
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; callID: string; metadata: { [x: string]: JsonValue } }
}
export type SessionCompactionDelta = {
id: string
created: number
@@ -1809,28 +1824,19 @@ export type SessionPendingUserData1 = {
metadata?: { [x: string]: any }
}
export type SessionMessageToolStateRunning = {
status: "running"
input: { [x: string]: JsonValue }
structured: { [x: string]: JsonValue }
content: Array<LLMToolContent>
}
export type SessionMessageToolStateCompleted = {
status: "completed"
input: { [x: string]: JsonValue }
content: Array<LLMToolContent>
structured: { [x: string]: JsonValue }
result?: JsonValue
content: [LLMToolContent, ...Array<LLMToolContent>]
metadata?: { [x: string]: JsonValue }
}
export type SessionMessageToolStateError = {
status: "error"
input: { [x: string]: JsonValue }
content: Array<LLMToolContent>
structured: { [x: string]: JsonValue }
error: SessionStructuredError
result?: JsonValue
content?: [LLMToolContent, ...Array<LLMToolContent>]
metadata?: { [x: string]: JsonValue }
}
export type SessionToolSuccess = {
@@ -1838,15 +1844,14 @@ export type SessionToolSuccess = {
created: number
metadata?: { [x: string]: any }
type: "session.tool.success"
durable: { aggregateID: string; seq: number; version: 1 }
durable: { aggregateID: string; seq: number; version: 2 }
location?: LocationRef
data: {
sessionID: string
assistantMessageID: string
callID: string
structured: { [x: string]: any }
content: Array<LLMToolContent>
result?: any
content: [LLMToolContent, ...Array<LLMToolContent>]
metadata?: { [x: string]: JsonValue }
executed: boolean
resultState?: SessionMessageProviderState6
}
@@ -1857,7 +1862,7 @@ export type SessionToolFailed = {
created: number
metadata?: { [x: string]: any }
type: "session.tool.failed"
durable: { aggregateID: string; seq: number; version: 1 }
durable: { aggregateID: string; seq: number; version: 2 }
location?: LocationRef
data: {
sessionID: string
@@ -1865,28 +1870,12 @@ export type SessionToolFailed = {
callID: string
error: SessionStructuredError
content?: [LLMToolContent, ...Array<LLMToolContent>]
metadata?: { [x: string]: any }
result?: any
metadata?: { [x: string]: JsonValue }
executed: boolean
resultState?: SessionMessageProviderState7
}
}
export type SessionToolProgress = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.tool.progress"
location?: LocationRef
data: {
sessionID: string
assistantMessageID: string
callID: string
structured: { [x: string]: any }
content: Array<LLMToolContent>
}
}
export type SessionMessageCompaction =
| SessionMessageCompactionRunning
| SessionMessageCompactionCompleted
+10 -11
View File
@@ -33,7 +33,7 @@ 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" }),
execute: ({ id }) => Effect.succeed({ id, status: "open" }),
})
const runtime = CodeMode.make({
@@ -55,10 +55,10 @@ const result = await Effect.runPromise(
### `Tool.make`
`input` and `output` accept either an Effect Schema or a render-only JSON Schema document. Effect Schema input is
decoded before `run`; Effect Schema output is decoded and safely copied before the program sees it. JSON Schemas only
shape the model-visible signature. Without `output`, the signature uses `Promise<unknown>`.
decoded before `execute`; Effect Schema output is decoded and safely copied before the program sees it. JSON Schemas
only shape the model-visible signature. Without `output`, the signature uses `Promise<void>`.
Descriptions and schemas are model-visible contracts. Authorization belongs in `run`.
Descriptions and schemas are model-visible contracts. Authorization belongs in `execute`.
Dots in tool names create namespaces: `{ "issues.list": tool }` and `{ issues: { list: tool } }` both expose
`tools.issues.list(...)`. Other characters use bracket notation, such as
@@ -72,7 +72,6 @@ Dots in tool names create namespaces: `{ "issues.list": tool }` and `{ issues: {
const runtime = CodeMode.make({ tools, limits: { timeoutMs: 30_000 } })
runtime.catalog() // structured tool descriptions
runtime.instructions() // model-facing syntax and tool guide
runtime.execute(source) // Effect<CodeMode.Result, never, ToolServices>
```
@@ -145,13 +144,13 @@ safe refusal to the model; its optional cause remains private.
## Discovery
Generated instructions contain a tool catalog with a default budget of 2,000 estimated tokens. Configure it with
`discovery: { catalogBudget }`. Every namespace remains visible, and the instructions say whether the catalog is
complete or partial.
`runtime.catalog()` returns structured descriptors — exact path, description, and generated TypeScript signature — for
every visible tool. Hosts render their own model-facing instructions from these descriptors; `CodeMode.searchSignature`
and `CodeMode.toolExpression(path)` supply the exact callable forms.
The synchronous `search(...)` built-in is always available and advertised when the catalog is partial. It supports
exact-path lookup, namespace-scoped search, empty-query browsing, and pagination, and returns callable paths with full
signatures. Search counts toward `maxToolCalls`.
The synchronous `search(...)` built-in is always available. It supports exact-path lookup, namespace-scoped search,
empty-query browsing, and pagination, and returns callable paths with full signatures. Search counts toward
`maxToolCalls`.
## Execution Limits
+1 -1
View File
@@ -188,7 +188,7 @@ ultimate source of truth.
first-call-wins resolve/reject functions, and ignore throws after settlement. Inherited/accessor `then` fields
and a JavaScript `this` receiver remain outside the supported object/function model.
- [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the
last definition supplied for a canonical path wins.
last tool supplied for a canonical path wins.
- [x] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys.
- [x] Outbound tool arguments follow JSON serialization semantics, like `JSON.stringify`: object properties with
`undefined` values are dropped, `undefined` array elements and non-finite numbers become `null`, and sparse
+4 -13
View File
@@ -5,6 +5,8 @@ import type { Tools } from "./tools.js"
/** A tool call admitted during an execution. */
export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js"
/** Signature-construction helpers for host-owned catalog instructions. */
export { searchSignature, toolExpression } from "./tool-runtime.js"
/** Resource budgets enforced independently during each CodeMode program execution. */
export type ExecutionLimits = {
@@ -22,12 +24,6 @@ export type ExecutionLimits = {
readonly maxOutputBytes?: number
}
/** Controls how much of the tool catalog is inlined in agent instructions. */
export type DiscoveryOptions = {
/** Approximate token budget (chars/4, default 2000) for full catalog entries. */
readonly catalogBudget?: number
}
export type ResolvedExecutionLimits = {
readonly timeoutMs: number | undefined
readonly maxToolCalls: number | undefined
@@ -52,10 +48,7 @@ export type ExecuteOptions<Provided extends Record<string, unknown> = {}> = {
export type DataValue = Schema.Json
/** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */
export type Options<Provided extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Provided>, "code"> & {
/** Progressive-disclosure configuration for the agent-facing tool catalog. */
readonly discovery?: DiscoveryOptions
}
export type Options<Provided extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Provided>, "code">
/** Schema for a host tool input containing CodeMode source. */
export const Input = Schema.Struct({ code: Schema.String })
@@ -116,7 +109,6 @@ export type Result = typeof Result.Type
/** Reusable confined runtime over explicit tools. */
export type Runtime<R = never> = {
readonly catalog: () => ReadonlyArray<ToolDescription>
readonly instructions: () => string
readonly execute: (code: string) => Effect.Effect<Result, never, R>
}
@@ -147,11 +139,10 @@ export const make = <const Provided extends Record<string, unknown> = {}>(
): Runtime<Services<Provided>> => {
const tools = (options.tools ?? {}) as Tools<Services<Provided>>
const limits = resolveExecutionLimits(options.limits)
const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget)
const prepared = ToolRuntime.prepare(tools)
return {
catalog: () => prepared.catalog,
instructions: () => prepared.instructions,
execute: (code) => executeWithLimits<Provided>({ ...options, code }, limits, prepared.searchIndex),
}
}
+1
View File
@@ -1,4 +1,5 @@
export * as CodeMode from "./codemode.js"
export * as Tool from "./tool.js"
export * as OpenAPI from "./openapi/index.js"
export { searchSignature, toolExpression } from "./codemode.js"
export { ToolError, toolError } from "./tool-error.js"
+1 -1
View File
@@ -45,7 +45,7 @@ export const executeWithLimits = <const Provided extends Record<string, unknown>
const program = parseProgram(options.code)
const promises = new PromiseRuntime<Services<Provided>>(scope)
const interpreter = new Interpreter<Services<Provided>>(
tools.invoke,
tools.execute,
tools.search,
tools.keys,
promises,
+8 -5
View File
@@ -271,7 +271,10 @@ const promiseResolutionNode: AstNode = { type: "PromiseResolution" }
export class Interpreter<R> {
private scopes: ScopeStack
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
private readonly executeTool: (
path: ReadonlyArray<string>,
args: Array<unknown>,
) => Effect.Effect<unknown, unknown, R>
private readonly invokeSearch: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
private readonly toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
private readonly logs: Array<string>
@@ -286,7 +289,7 @@ export class Interpreter<R> {
}
constructor(
invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
executeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
invokeSearch: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
promises: PromiseRuntime<R>,
@@ -294,7 +297,7 @@ export class Interpreter<R> {
) {
const globalScope = new Map<string, Binding>()
this.scopes = new ScopeStack([globalScope])
this.invokeTool = invokeTool
this.executeTool = executeTool
this.invokeSearch = invokeSearch
this.toolKeys = toolKeys
this.logs = logs
@@ -369,7 +372,7 @@ export class Interpreter<R> {
path: ReadonlyArray<string>,
args: Array<unknown>,
): Effect.Effect<CodeModePromise, never, R> {
return this.createPromise(Effect.suspend(() => this.invokeTool(path, args)))
return this.createPromise(Effect.suspend(() => this.executeTool(path, args)))
}
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<CodeModePromise, never, R> {
@@ -2079,7 +2082,7 @@ export class Interpreter<R> {
}
private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
const invocation = new Interpreter(this.invokeTool, this.invokeSearch, this.toolKeys, this.promises, this.logs)
const invocation = new Interpreter(this.executeTool, this.invokeSearch, this.toolKeys, this.promises, this.logs)
invocation.scopes = new ScopeStack([...fn.capturedScopes, new Map()])
const run = Effect.gen(function* () {
// Seed all parameters first so defaults cannot fall through to same-named outer bindings.
+5 -5
View File
@@ -1,5 +1,5 @@
import { HttpClient } from "effect/unstable/http"
import { make, type Definition } from "../tool.js"
import { make, type Tool } from "../tool.js"
import { invoke } from "./runtime.js"
import {
componentDefinitions,
@@ -108,7 +108,7 @@ export const fromSpec = (options: Options): Result => {
description: operation.description ?? operation.summary ?? `${operation.method} ${path}`,
input: inputSchema(input.fields, requestDefinitions),
output: output.value,
run: (input) => invoke(plan, input),
execute: (input) => invoke(plan, input),
}),
)
}
@@ -117,16 +117,16 @@ export const fromSpec = (options: Options): Result => {
return { tools, skipped }
}
const setTool = (tools: Tools, path: ReadonlyArray<string>, definition: Definition<HttpClient.HttpClient>): void => {
const setTool = (tools: Tools, path: ReadonlyArray<string>, tool: Tool<HttpClient.HttpClient>): void => {
const [head, ...rest] = path
if (head === undefined) return
if (rest.length === 0) {
tools[head] = definition
tools[head] = tool
return
}
const child = tools[head]
if (child === undefined || !isRecord(child) || child._tag === "CodeModeTool") {
tools[head] = Object.create(null) as Tools
}
setTool(tools[head] as Tools, rest, definition)
setTool(tools[head] as Tools, rest, tool)
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
import type { Definition, JsonSchema } from "../tool.js"
import type { Tool, JsonSchema } from "../tool.js"
/** A parsed OpenAPI 3.x document. YAML must be parsed by the host. */
export type Document = Record<string, unknown>
@@ -58,7 +58,7 @@ export type Skipped = {
readonly reason: string
}
export type Tools = { [name: string]: Definition<HttpClient.HttpClient> | Tools }
export type Tools = { [name: string]: Tool<HttpClient.HttpClient> | Tools }
export type Result = {
/** Namespaced tools; the host places them under a key in its `tools` object. */
+46 -189
View File
@@ -8,7 +8,7 @@ import {
inputTypeScript,
outputTypeScript,
} from "./tool-schema.js"
import { isDefinition as isToolDefinition, type Definition } from "./tool.js"
import { isTool, type Tool } from "./tool.js"
import type { Tools } from "./tools.js"
import {
CodeModeDate,
@@ -20,15 +20,13 @@ import {
CodeModeURLSearchParams,
} from "./values.js"
const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4))
export type Services<T> = ServicesOf<T, []>
type ServicesOf<T, Depth extends ReadonlyArray<unknown>> = Depth["length"] extends 8
? never
: T extends {
readonly _tag: "CodeModeTool"
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
readonly execute: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
}
? R
: T extends object
@@ -69,7 +67,6 @@ export type ToolDescription = {
export type SafeObject = Record<string, unknown>
const defaultCatalogBudget = 2_000
const defaultSearchLimit = 10
const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
@@ -89,7 +86,7 @@ const SearchOutput = Schema.Struct({
remaining: NonNegativeInt,
next: Schema.NullOr(Schema.Struct({ offset: NonNegativeInt })),
})
const toolExpression = (path: string) =>
export const toolExpression = (path: string) =>
"tools" +
path
.split(".")
@@ -118,8 +115,6 @@ export class ToolRuntimeError extends Error {
}
}
const isDefinition = <R>(value: Definition<R> | Tools<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) => {
@@ -286,9 +281,9 @@ export const copyOut = (value: unknown, mode: CopyOutMode): unknown => {
return value
}
// Dots in tool names are namespace separators; the last definition for a canonical path wins.
// Dots in tool names are namespace separators; the last tool for a canonical path wins.
type ToolNode<R> = {
definition?: Definition<R>
tool?: Tool<R>
readonly children: Map<string, ToolNode<R>>
}
@@ -303,7 +298,7 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
current.children.set(segment, child)
current = child
}
if (isDefinition(value)) current.definition = value
if (isTool<R>(value)) current.tool = value
else insert(current, value)
}
}
@@ -314,30 +309,29 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
const canonicalSegments = (path: ReadonlyArray<string>): ReadonlyArray<string> =>
path.flatMap((segment) => segment.split("."))
const definitions = <R>(
const flattenTools = <R>(
node: ToolNode<R>,
path: ReadonlyArray<string> = [],
): Array<{ path: string; definition: Definition<R> }> => [
...(node.definition === undefined ? [] : [{ path: path.join("."), definition: node.definition }]),
...Array.from(node.children, ([name, child]) => definitions(child, [...path, name])).flat(),
): Array<{ path: string; tool: Tool<R> }> => [
...(node.tool === undefined ? [] : [{ path: path.join("."), tool: node.tool }]),
...Array.from(node.children, ([name, child]) => flattenTools(child, [...path, name])).flat(),
]
const describeDefinition = <R>(path: string, definition: Definition<R>): ToolDescription => ({
const describeTool = <R>(path: string, tool: Tool<R>): ToolDescription => ({
path,
description: definition.description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`,
description: tool.description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(tool, true)}): Promise<${outputTypeScript(tool, true)}>`,
})
const visibleDefinitions = <R>(tools: Tools<R>) =>
definitions(toolTrie(tools)).map(({ path, definition }) => ({
const visibleTools = <R>(tools: Tools<R>) =>
flattenTools(toolTrie(tools)).map(({ path, tool }) => ({
path,
definition,
description: describeDefinition(path, definition),
tool,
description: describeTool(path, tool),
}))
export type DiscoveryPlan = {
readonly catalog: ReadonlyArray<ToolDescription>
readonly instructions: string
readonly searchIndex: ReadonlyArray<SearchEntry>
}
@@ -361,12 +355,12 @@ const termForms = (term: string): Array<string> => {
return forms
}
const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition => ({
const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
_tag: "CodeModeTool",
description: "Search available tools",
input: SearchInput,
output: SearchOutput,
run: (input) =>
execute: (input) =>
Effect.sync(() => {
const request = input as typeof SearchInput.Type
const query = request.query ?? ""
@@ -421,24 +415,19 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition =>
}),
})
const searchSignature = (() => {
const definition = makeSearchTool([])
return `search(input: ${inputTypeScript(definition, true)}): ${outputTypeScript(definition, true)}`
/** Exact callable signature of the built-in `search` function, for host-owned instructions. */
export const searchSignature = (() => {
const tool = makeSearchTool([])
return `search(input: ${inputTypeScript(tool, true)}): ${outputTypeScript(tool, true)}`
})()
const catalogLine = (tool: ToolDescription) => {
const line = tool.description.split("\n", 1)[0]!.trim()
const description = line.length > 120 ? line.slice(0, 119) + "..." : line
return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}`
}
const toSearchEntry = <R>(path: string, definition: Definition<R>, description: ToolDescription): SearchEntry => ({
const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescription): SearchEntry => ({
description,
namespace: path.split(".", 1)[0]!,
searchText: [
path,
definition.description,
...inputProperties(definition).flatMap(({ name, description: property }) =>
tool.description,
...inputProperties(tool).flatMap(({ name, description: property }) =>
property === undefined ? [name] : [name, property],
),
]
@@ -447,149 +436,13 @@ const toSearchEntry = <R>(path: string, definition: Definition<R>, description:
})
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> =>
visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description))
visibleTools(tools).map(({ path, tool, description }) => toSearchEntry(path, tool, description))
// Budget signatures round-robin so every namespace remains visible.
export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget): DiscoveryPlan => {
if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) {
throw new RangeError("discovery.catalogBudget 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))
const selections = ordered.map(([namespace, group]) => ({
namespace,
picked: new Set<ToolDescription>(),
queue: [...group].sort(
(left, right) =>
estimateTokens(catalogLine(left)) - estimateTokens(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 = estimateTokens(catalogLine(tool))
if (used + cost > catalogBudget) 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
const intro = [
empty
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime."
: complete
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the tools listed below; surrounding agent tools are not available."
: "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the tools listed or searchable below; surrounding agent tools are not available.",
...(empty
? []
: ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]),
]
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 result = await tools.<namespace>.<tool>(input)`; bracket notation and quotes are part of the path.",
"3. Return only the fields you need from structured results; narrow unknown results before reading fields, and avoid returning large raw payloads.",
]
: [
'1. If needed, discover tools with the built-in search function: `return search({ query: "<intent + key nouns>" })`.',
"2. In the next execution, copy a returned path exactly, call it, and return only the needed fields.",
]),
]
const rules = empty
? []
: [
"",
"## Rules",
"",
complete
? "- Only tools listed here are available; surrounding agent tools are not implicitly exposed."
: "- Only tools listed here or returned by the built-in `search` function are available; surrounding agent tools 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>` may be structured data or text. Before reading fields, check that it is a non-null object and not an array; otherwise handle the returned text or primitive directly.",
'- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`, or use `tools.<namespace>["tool-name"](item)` when the listed signature uses bracket notation.',
"- Execution ends when the program returns; pending promises are interrupted, so await every call whose completion matters.",
"- `Object.keys(tools)` lists namespaces; `Object.keys(tools.<namespace>)` lists its tools; `for...in` works on both.",
...(complete
? []
: [
'- Browse one namespace: `search({ query: "", namespace: "<name>" })`.',
"- If search returns `next`, repeat the same search with `offset: next.offset`.",
]),
]
const language = [
"",
"## Language",
"",
"Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.",
"Modules/imports, classes, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use tools for external operations. Use await with try/catch.",
"Prefer explicit `return`; otherwise only the final top-level expression becomes the result.",
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams 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 search(...))`,
"",
)
for (const [namespace, group] of ordered) {
const picked = shown.get(namespace)!
const count = `${group.length} tool${group.length === 1 ? "" : "s"}`
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, ...language, ...toolSection]
export const prepare = <R>(tools: Tools<R>): DiscoveryPlan => {
const visible = visibleTools(tools)
return {
catalog: described,
instructions: lines.join("\n"),
searchIndex: visible.map(({ path, definition, description }) => toSearchEntry(path, definition, description)),
catalog: visible.map(({ description }) => description),
searchIndex: visible.map(({ path, tool, description }) => toSearchEntry(path, tool, description)),
}
}
@@ -605,24 +458,24 @@ const namespaceKeys = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Reado
return Array.from(node.children.keys())
}
const resolve = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Definition<R> => {
const resolve = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Tool<R> => {
const segments = canonicalSegments(path)
const node = lookup(root, segments)
if (node === undefined) {
throw new ToolRuntimeError("UnknownTool", `Unknown tool '${segments.join(".")}'.`, [
"Use search({ query }) to find available described tools.",
"The tool may have been removed or renamed. Use search to find available tools.",
])
}
if (node.definition === undefined) {
if (node.tool === undefined) {
throw new ToolRuntimeError("UnknownTool", `Tool '${segments.join(".")}' is not callable.`)
}
return node.definition
return node.tool
}
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>
readonly execute: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
readonly search: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
readonly keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
}
@@ -676,19 +529,23 @@ export const make = <R>(
return calls.length - 1
}).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void))
const invokeDefinition = (name: string, tool: Definition<R>, externalArgs: Array<unknown>) =>
const executeTool = (name: string, tool: Tool<R>, externalArgs: Array<unknown>) =>
Effect.gen(function* () {
if (externalArgs.length !== 1)
throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
const input = yield* Effect.try({
try: () => decodeToolInput(tool, externalArgs[0]),
catch: (cause) =>
new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`),
new ToolRuntimeError(
"InvalidToolInput",
`Invalid input for tool '${name}': ${String(cause)}`,
name === "search" ? [] : ["The signature may have changed. Use search to get the current signature."],
),
})
const index = yield* recordAndObserve(name, input)
return yield* observeEnd(
Effect.gen(function* () {
const raw = yield* runHost(Effect.suspend(() => tool.run(input)))
const raw = yield* runHost(Effect.suspend(() => tool.execute(input)))
const result = yield* Effect.try({
try: () => decodeToolOutput(tool, raw),
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
@@ -705,18 +562,18 @@ export const make = <R>(
keys: (path) => namespaceKeys(root, path),
search: (args) =>
Effect.suspend(() =>
invokeDefinition(
executeTool(
"search",
searchTool,
args.map((arg) => copyOut(copyIn(arg, "Arguments for tool 'search'"), "json")),
),
),
invoke: (path, args) =>
execute: (path, args) =>
Effect.gen(function* () {
const name = canonicalSegments(path).join(".")
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`), "json"))
const tool = resolve(root, path)
return yield* invokeDefinition(name, tool, externalArgs)
return yield* executeTool(name, tool, externalArgs)
}),
}
}
+22 -22
View File
@@ -1,5 +1,5 @@
import { JsonPointer, Schema } from "effect"
import type { Definition, JsonSchema, SchemaType } from "./tool.js"
import type { Tool, JsonSchema, SchemaType } from "./tool.js"
const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder<unknown> & Schema.Top => Schema.isSchema(schema)
@@ -192,16 +192,16 @@ export type InputProperty = {
readonly required: boolean
}
export const inputProperties = <R>(definition: Definition<R>): Array<InputProperty> => {
export const inputProperties = <R>(tool: Tool<R>): Array<InputProperty> => {
try {
const document = isEffectSchema(definition.input)
? (Schema.toJsonSchemaDocument(definition.input) as {
const document = isEffectSchema(tool.input)
? (Schema.toJsonSchemaDocument(tool.input) as {
readonly schema: JsonSchema
readonly definitions?: Readonly<Record<string, JsonSchema>>
})
: {
schema: definition.input,
definitions: { ...(definition.input.definitions ?? {}), ...(definition.input.$defs ?? {}) },
schema: tool.input,
definitions: { ...(tool.input.definitions ?? {}), ...(tool.input.$defs ?? {}) },
}
const definitions = document.definitions ?? {}
let schema = document.schema
@@ -223,22 +223,22 @@ export const inputProperties = <R>(definition: Definition<R>): Array<InputProper
}
}
export const inputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
isEffectSchema(definition.input)
? toTypeScript(definition.input, false, pretty)
: jsonSchemaToTypeScript(definition.input, pretty)
export const inputTypeScript = <R>(tool: Tool<R>, pretty = false): string =>
isEffectSchema(tool.input) ? toTypeScript(tool.input, false, pretty) : jsonSchemaToTypeScript(tool.input, pretty)
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)
export const outputTypeScript = <R>(tool: Tool<R>, pretty = false): string =>
tool.output === undefined
? "void"
: isEffectSchema(tool.output)
? toTypeScript(tool.output, true, pretty)
: jsonSchemaToTypeScript(tool.output, pretty)
export const decodeInput = <R>(definition: Definition<R>, value: unknown): unknown =>
isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value
export const decodeInput = <R>(tool: Tool<R>, value: unknown): unknown =>
isEffectSchema(tool.input) ? Schema.decodeUnknownSync(tool.input)(value) : value
export const decodeOutput = <R>(definition: Definition<R>, value: unknown): unknown =>
definition.output !== undefined && isEffectSchema(definition.output)
? Schema.decodeUnknownSync(definition.output)(value)
: value
export const decodeOutput = <R>(tool: Tool<R>, value: unknown): unknown =>
tool.output === undefined
? undefined
: isEffectSchema(tool.output)
? Schema.decodeUnknownSync(tool.output)(value)
: value
+12 -12
View File
@@ -29,29 +29,29 @@ export type JsonSchema = {
/** Either a validating Effect Schema or a render-only JSON Schema document. */
export type SchemaType = Schema.Decoder<unknown> | JsonSchema
/** Schema-backed tool definition exposed through CodeMode's `tools` object. */
export type Definition<R = never> = {
/** Executable tool tool exposed through CodeMode's `tools` object. */
export type Tool<R = never> = {
readonly _tag: "CodeModeTool"
readonly description: string
readonly input: SchemaType
readonly output: SchemaType | undefined
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, R>
readonly execute: (input: unknown) => Effect.Effect<unknown, unknown, R>
}
type InputType<S> = S extends Schema.Decoder<unknown> ? S["Type"] : unknown
type ResultType<S> = S extends Schema.Decoder<unknown> ? S["Encoded"] : unknown
type ResultType<S> = S extends undefined ? void : S extends Schema.Decoder<unknown> ? S["Encoded"] : unknown
/** Options for defining one CodeMode tool. */
/** Options for declaring one CodeMode tool. */
export type Options<I extends SchemaType, O extends SchemaType | undefined, R = never> = {
readonly description: string
readonly input: I
readonly output?: O
readonly run: (input: InputType<I>) => Effect.Effect<ResultType<O>, unknown, R>
readonly execute: (input: InputType<I>) => Effect.Effect<ResultType<O>, unknown, R>
}
// Object.hasOwn: an inherited _tag must not classify a namespace as a Definition.
export const isDefinition = <R = never>(value: unknown): value is Definition<R> =>
// Object.hasOwn: an inherited _tag must not classify a namespace as a Tool.
export const isTool = <R = never>(value: unknown): value is Tool<R> =>
typeof value === "object" &&
value !== null &&
"_tag" in value &&
@@ -59,18 +59,18 @@ export const isDefinition = <R = never>(value: unknown): value is Definition<R>
value._tag === "CodeModeTool"
/**
* Defines one schema-described tool available to a CodeMode program through `tools.*`.
* Declares one schema-described tool available to a CodeMode program through `tools.*`.
*
* Effect Schemas validate values; JSON Schemas only shape the model-visible signature.
* Without `output`, results are exposed as `unknown`. Hosts remain responsible for authorization
* Without `output`, results are exposed as `void`. Hosts remain responsible for authorization
* and durable side effects.
*/
export const make = <I extends SchemaType, const O extends SchemaType | undefined = undefined, R = never>(
options: Options<I, O, R>,
): Definition<R> => ({
): Tool<R> => ({
_tag: "CodeModeTool",
description: options.description,
input: options.input,
output: options.output,
run: (input) => options.run(input as InputType<I>),
execute: (input) => options.execute(input as InputType<I>),
})
+2 -2
View File
@@ -1,5 +1,5 @@
import type { Definition } from "./tool.js"
import type { Tool } from "./tool.js"
export type Tools<R = never> = {
readonly [name: string]: Definition<R> | Tools<R>
readonly [name: string]: Tool<R> | Tools<R>
}
+1 -1
View File
@@ -27,7 +27,7 @@ const echo = Tool.make({
description: "Echo the input",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Number,
run: (input: { id: number }) => Effect.succeed(input.id),
execute: (input: { id: number }) => Effect.succeed(input.id),
})
const withTool = (code: string) => Effect.runPromise(CodeMode.make({ tools: { host: { echo } } }).execute(code))
const toolError = async (code: string) => {
+37 -200
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { Cause, Effect, Schema } from "effect"
import { CodeMode, Tool, toolError } from "../src/index.js"
const run = (tool: Tool.Definition<never>) =>
const run = (tool: Tool.Tool<never>) =>
Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})"))
class UnsafeHostError extends Schema.TaggedErrorClass<UnsafeHostError>()("UnsafeHostError", {
@@ -16,7 +16,7 @@ describe("CodeMode host failure boundary", () => {
description: "Fail safely",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.fail(toolError("Authorized request was refused")),
execute: () => Effect.fail(toolError("Authorized request was refused")),
}),
)
@@ -32,7 +32,7 @@ describe("CodeMode host failure boundary", () => {
description: "Fail safely",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.fail(toolError("File not found: /tmp/report.json")),
execute: () => Effect.fail(toolError("File not found: /tmp/report.json")),
}),
)
@@ -52,7 +52,7 @@ describe("CodeMode host failure boundary", () => {
description: "Fail internally",
input: Schema.Struct({}),
output: Schema.String,
run: () => failure,
execute: () => failure,
}),
)
@@ -71,7 +71,7 @@ describe("CodeMode host failure boundary", () => {
description: "Return invalid output",
input: Schema.Struct({}),
output: Schema.Struct({ safe: Schema.String }),
run: () => Effect.succeed({ safe: 1, secret } as unknown as { readonly safe: string }),
execute: () => Effect.succeed({ safe: 1, secret } as unknown as { readonly safe: string }),
}),
)
@@ -88,7 +88,7 @@ describe("CodeMode host failure boundary", () => {
description: "Return hostile output",
input: Schema.Struct({}),
output: Schema.Unknown,
run: () =>
execute: () =>
Effect.succeed(
new Proxy(
{},
@@ -118,7 +118,7 @@ describe("CodeMode host failure boundary", () => {
description: "Refuse",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.fail(toolError("Refused")),
execute: () => Effect.fail(toolError("Refused")),
}),
},
},
@@ -145,7 +145,7 @@ describe("CodeMode host failure boundary", () => {
description: "Interrupt",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.interrupt,
execute: () => Effect.interrupt,
}),
},
},
@@ -166,7 +166,7 @@ describe("CodeMode tool-call observation", () => {
description: "Look up a value",
input: Schema.Struct({ query: Schema.String }),
output: Schema.String,
run: ({ query }) => Effect.succeed(query),
execute: ({ query }) => Effect.succeed(query),
})
const result = await Effect.runPromise(
@@ -189,7 +189,7 @@ describe("CodeMode tool-call observation", () => {
description: "Look up a value",
input: Schema.Struct({ query: Schema.String }),
output: Schema.String,
run: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)),
execute: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)),
})
const runtime = CodeMode.make({
@@ -430,7 +430,7 @@ describe("CodeMode schema flexibility", () => {
properties: { id: { type: "string" }, count: { type: "number" } },
required: ["id"],
},
run: (input) =>
execute: (input) =>
Effect.sync(() => {
observed.push(input)
return { echoed: input }
@@ -442,14 +442,14 @@ describe("CodeMode schema flexibility", () => {
{
path: "adapter.call",
description: "Call an adapter-described tool",
signature: "tools.adapter.call(input: {\n id: string,\n count?: number,\n}): Promise<unknown>",
signature: "tools.adapter.call(input: {\n id: string,\n count?: number,\n}): Promise<void>",
},
])
// JSON Schema is render-only: mistyped input passes through unvalidated.
const result = await Effect.runPromise(runtime.execute(`return await tools.adapter.call({ id: 42 })`))
expect(result.ok).toBe(true)
if (result.ok) expect(result.value).toStrictEqual({ echoed: { id: 42 } })
if (result.ok) expect(result.value).toBeNull()
expect(observed).toStrictEqual([{ id: 42 }])
})
@@ -458,7 +458,7 @@ describe("CodeMode schema flexibility", () => {
const call = Tool.make({
description: "Observe raw input",
input: { type: "object" },
run: (input) =>
execute: (input) =>
Effect.sync(() => {
observed.push(input)
return "ok"
@@ -483,7 +483,7 @@ describe("CodeMode schema flexibility", () => {
const find = Tool.make({
description: "Find things",
input: Schema.Struct({ query: Schema.optionalKey(Schema.String), limit: Schema.optionalKey(Schema.Number) }),
run: (input) =>
execute: (input) =>
Effect.sync(() => {
observed.push(input)
return "ok"
@@ -517,7 +517,7 @@ describe("CodeMode schema flexibility", () => {
},
},
},
run: () => Effect.succeed({ login: "kit", id: 7 }),
execute: () => Effect.succeed({ login: "kit", id: 7 }),
})
const runtime = CodeMode.make({ tools: { users: { lookup } } })
@@ -534,18 +534,18 @@ describe("CodeMode schema flexibility", () => {
if (result.ok) expect(result.value).toStrictEqual({ login: "kit", id: 7 })
})
test("Effect Schema output without an input transform still renders unknown when omitted", async () => {
test("Effect Schema output without an input transform renders void when omitted", async () => {
const ping = Tool.make({
description: "Ping",
input: Schema.Struct({ host: Schema.String }),
run: () => Effect.succeed("pong"),
execute: () => Effect.succeed("pong"),
})
const runtime = CodeMode.make({ tools: { net: { ping } } })
expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise<unknown>")
expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise<void>")
const result = await Effect.runPromise(runtime.execute(`return await tools.net.ping({ host: "example.test" })`))
expect(result.ok).toBe(true)
if (result.ok) expect(result.value).toBe("pong")
if (result.ok) expect(result.value).toBeNull()
})
})
@@ -554,7 +554,7 @@ describe("CodeMode public contract", () => {
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" }),
execute: ({ id }) => Effect.succeed({ id, status: "open" }),
})
const tools = { orders: { lookup } }
const source = `return await tools.orders.lookup({ id: "order_42" })`
@@ -577,7 +577,7 @@ describe("CodeMode public contract", () => {
description: "echo",
input: Schema.Struct({}),
output: Schema.Number,
run: () => Effect.succeed(1),
execute: () => Effect.succeed(1),
})
const effect = CodeMode.execute({
tools: { host: { echo } },
@@ -592,7 +592,7 @@ describe("CodeMode public contract", () => {
expect(second).toStrictEqual({ ok: true, value: 1, logs: ["hi"], toolCalls: [{ name: "host.echo" }] })
})
test("inlines a COMPLETE small catalog and keeps search registered but unadvertised", async () => {
test("describes the catalog and keeps the search built-in registered", async () => {
const runtime = CodeMode.make({ tools })
expect(runtime.catalog()).toStrictEqual([
{
@@ -601,16 +601,7 @@ describe("CodeMode public contract", () => {
signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>",
},
])
expect(runtime.instructions()).toContain("Available tools (COMPLETE list")
expect(runtime.instructions()).toContain("- orders (1 tool)")
expect(runtime.instructions()).toContain(
" - tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}> // Look up an order by ID",
)
// A fully inlined catalog does not advertise search in the instructions...
expect(runtime.instructions()).not.toContain("search(")
// ...but the search built-in stays available, so a speculative call still works with the
// same signature as the inline catalog.
const result = await Effect.runPromise(runtime.execute(`return search({ query: "order" })`))
expect(result.ok).toBe(true)
if (result.ok) {
@@ -634,7 +625,7 @@ describe("CodeMode public contract", () => {
description: "Resolve a library ID",
input: Schema.Struct({ libraryName: Schema.String }),
output: Schema.String,
run: ({ libraryName }) => Effect.succeed(`/resolved/${libraryName}`),
execute: ({ libraryName }) => Effect.succeed(`/resolved/${libraryName}`),
})
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
@@ -645,9 +636,6 @@ describe("CodeMode public contract", () => {
signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise<string>',
},
])
expect(runtime.instructions()).toContain(
'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise<string>',
)
const search = await Effect.runPromise(runtime.execute(`return search({ query: "resolve library id" })`))
expect(search.ok).toBe(true)
@@ -678,110 +666,22 @@ describe("CodeMode public contract", () => {
if (exact.ok) expect(exact.value).toMatchObject({ remaining: 0, next: null })
})
test("instructions use markdown sections with placeholder-only call forms", () => {
const runtime = CodeMode.make({ tools })
const instructions = runtime.instructions()
// Sections in order: workflow at the top, catalog at the bottom.
expect(instructions).toContain("## Workflow")
expect(instructions).toContain("## Rules")
expect(instructions).toContain("## Language")
expect(instructions.indexOf("## Workflow")).toBeLessThan(instructions.indexOf("## Rules"))
expect(instructions.indexOf("## Rules")).toBeLessThan(instructions.indexOf("## Language"))
expect(instructions.indexOf("## Language")).toBeLessThan(
instructions.indexOf("\n## Available tools (COMPLETE list"),
)
expect(instructions).not.toContain("JSON.parse(res)")
expect(instructions).toContain("Return only the fields you need")
expect(instructions).toContain("avoid returning large raw payloads")
expect(instructions).toContain("Do not infer or normalize tool names")
expect(instructions).toContain("bracket notation and quotes are part of the path")
expect(instructions).toContain("surrounding agent tools are not available")
expect(instructions).toContain("Only tools listed here are available")
// Placeholders use generic namespace/tool/field names only - no fabricated real tools
// and no real catalog tools cherry-picked into example lines.
expect(instructions).toContain("`const result = await tools.<namespace>.<tool>(input)`")
expect(instructions).toContain("Return only the fields you need from structured results")
expect(instructions).toContain("check that it is a non-null object and not an array")
expect(instructions).not.toContain("result.<field>")
expect(instructions).not.toContain("data.<field>")
expect(instructions).not.toContain("total_count")
expect(instructions).not.toContain("list_issues")
expect(instructions).not.toContain("tools.orders.lookup({")
// COMPLETE: step 1 picks from the inlined list; search is not advertised.
expect(instructions).toContain("1. Pick a tool from the list under `## Available tools`")
expect(instructions).not.toContain("Browse one namespace")
const partial = CodeMode.make({ tools, discovery: { catalogBudget: 0 } }).instructions()
// PARTIAL: the workflow starts with search (with query-style guidance that is clearly
// a query string, never a tool name) and the browse-namespace rule appears.
expect(partial).toContain(
'1. If needed, discover tools with the built-in search function: `return search({ query: "<intent + key nouns>" })`.',
)
expect(partial).toContain("In the next execution, copy a returned path exactly")
expect(partial).toContain("Only tools listed here or returned by the built-in `search` function")
expect(partial).toContain('- Browse one namespace: `search({ query: "", namespace: "<name>" })`.')
expect(partial).toContain("repeat the same search with `offset: next.offset`")
expect(partial).toContain(" limit?: number,\n offset?: number,")
expect(partial).not.toContain("total_count")
expect(partial).not.toContain("tools.orders.lookup({")
})
test("the language section describes the restricted runtime without overclaiming", () => {
const instructions = CodeMode.make({ tools }).instructions()
expect(instructions).toContain("restricted JavaScript language for calling tools")
expect(instructions).toContain("not a general-purpose runtime")
expect(instructions).not.toContain("Standard modern JavaScript works")
expect(instructions).not.toContain("TypeScript type annotations")
for (const missing of ["Modules/imports", "classes", "fetch"]) {
expect(instructions).toContain(missing)
}
expect(instructions).not.toContain("generators")
expect(instructions).not.toContain("new Promise(...) are unavailable")
expect(instructions).not.toContain("promise chaining")
expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers")
expect(instructions).not.toContain("host globals")
expect(instructions).toContain("Use tools for external operations")
expect(instructions).toContain(
"Prefer explicit `return`; otherwise only the final top-level expression becomes the result.",
)
expect(instructions).toContain(
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
)
})
test("zero tools keep minimal sections and the no-tools notice", () => {
const runtime = CodeMode.make({})
const instructions = runtime.instructions()
expect(instructions).toContain("No tools are currently available.")
expect(instructions).toContain("## Language")
expect(instructions).toContain("## Available tools")
expect(instructions).not.toContain("## Workflow")
expect(instructions).not.toContain("## Rules")
expect(instructions).not.toContain("search(")
})
test("uses one ranked search returning complete definitions for large catalogs", async () => {
test("uses one ranked search returning complete tools for large catalogs", async () => {
const upload = Tool.make({
description: "Upload one readable local file to the current Discord thread",
input: Schema.Struct({ path: Schema.String }),
output: Schema.Struct({ sent: Schema.Boolean }),
run: () => Effect.succeed({ sent: true }),
execute: () => Effect.succeed({ sent: true }),
})
const generate = Tool.make({
description: "Generate an image and upload it to the current Discord thread",
input: Schema.Struct({ prompt: Schema.String }),
output: Schema.Struct({ sent: Schema.Boolean }),
run: () => Effect.succeed({ sent: true }),
execute: () => Effect.succeed({ sent: true }),
})
const runtime = CodeMode.make({
tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } },
discovery: { catalogBudget: 0 },
})
expect(runtime.instructions()).toContain("Available tools (PARTIAL - 0 of 3 shown; find the rest with search(...))")
expect(runtime.instructions()).toContain("- thread (2 tools, none shown)")
expect(runtime.instructions()).toContain("- orders (1 tool, none shown)")
expect(runtime.instructions()).toContain("Search returns complete callable signatures:\n- search(input: {")
expect(runtime.instructions()).not.toMatch(/tools\.thread\.uploadFile\(input/)
const result = await Effect.runPromise(
runtime.execute(`
@@ -865,7 +765,7 @@ describe("CodeMode public contract", () => {
description: `Numbered tool ${index}`,
input: Schema.Struct({ id: Schema.String }),
output: Schema.String,
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({
tools: {
@@ -911,7 +811,7 @@ describe("CodeMode public contract", () => {
description,
input: Schema.Struct({ id: Schema.String }),
output: Schema.String,
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({
tools: {
@@ -954,13 +854,13 @@ describe("CodeMode public contract", () => {
properties: { attachment: { type: "string", description: "Local path of the payload to send" } },
required: ["attachment"],
},
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
const other = Tool.make({
description: "Rename the workspace",
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({ tools: { files: { upload, other } } })
@@ -990,7 +890,7 @@ describe("CodeMode public contract", () => {
description,
input: Schema.Struct({ id: Schema.String }),
output: Schema.String,
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({
tools: {
@@ -1029,7 +929,7 @@ describe("CodeMode public contract", () => {
description,
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.succeed("ok"),
execute: () => Effect.succeed("ok"),
})
// Deliberately declared out of alphabetical order.
const runtime = CodeMode.make({
@@ -1066,71 +966,13 @@ describe("CodeMode public contract", () => {
if (exhausted.ok) expect(exhausted.value).toStrictEqual({ items: [], remaining: 0, next: null })
})
test("inlines round-robin across namespaces so one expensive namespace cannot starve the rest", () => {
const cheap = Tool.make({
description: "Cheap",
input: Schema.Struct({ q: Schema.String }),
output: Schema.String,
run: () => Effect.succeed("ok"),
})
const expensive = Tool.make({
description:
"An expensive tool whose description alone consumes far more than the remaining inline catalog byte budget for this runtime",
input: Schema.Struct({
someRatherLongParameterName: Schema.String,
anotherEvenLongerParameterName: Schema.Number,
}),
output: Schema.String,
run: () => Effect.succeed("ok"),
})
// Round 1 places alpha.cheap (~17 estimated tokens) and beta.cheap (~17); in round 2
// alpha.expensive does not fit, which marks only alpha done - it must NOT prevent
// other namespaces from inlining (beta already got its line in the same round).
const runtime = CodeMode.make({
tools: { alpha: { cheap, expensive }, beta: { cheap } },
discovery: { catalogBudget: 40 },
})
const instructions = runtime.instructions()
expect(instructions).toContain("Available tools (PARTIAL - 2 of 3 shown; find the rest with search(...))")
expect(instructions).toContain("- alpha (2 tools, 1 shown)")
expect(instructions).toContain(" - tools.alpha.cheap(input: {\n q: string,\n}): Promise<string> // Cheap")
expect(instructions).not.toContain("tools.alpha.expensive(")
// Fully shown namespaces read cleanly (no "shown" annotation).
expect(instructions).toContain("- beta (1 tool)")
expect(instructions).toContain(" - tools.beta.cheap(input: {\n q: string,\n}): Promise<string> // Cheap")
expect(instructions).toContain("Search returns complete callable signatures:\n- search(input: {")
})
test("charges inline JSDoc against the catalog token budget", () => {
const documented = Tool.make({
description: "Look up a record",
input: {
type: "object",
properties: {
id: { type: "string", description: "A detailed identifier description. ".repeat(20) },
},
required: ["id"],
} as const,
run: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({
tools: { records: { lookup: documented } },
discovery: { catalogBudget: 40 },
})
expect(runtime.catalog()[0]?.signature).toContain("/** A detailed identifier description.")
expect(runtime.instructions()).toContain("Available tools (PARTIAL - 0 of 1 shown; find the rest with search(...))")
expect(runtime.instructions()).not.toContain("tools.records.lookup(input:")
})
test("decodes tool input and output before exposing either side", async () => {
const observed: Array<unknown> = []
const transformed = Tool.make({
description: "Double a number",
input: Schema.Struct({ value: Schema.NumberFromString }),
output: Schema.NumberFromString,
run: ({ value }) =>
execute: ({ value }) =>
Effect.sync(() => {
observed.push(value)
return String(value * 2)
@@ -1184,7 +1026,7 @@ describe("CodeMode public contract", () => {
expect(result).toStrictEqual({ ok: true, value: null, toolCalls: [] })
})
test("rejects invalid configuration and discovery limits", async () => {
test("rejects invalid configuration and search limits", async () => {
expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: 0 } })).toThrow(RangeError)
expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow(
RangeError,
@@ -1192,13 +1034,8 @@ describe("CodeMode public contract", () => {
expect(() => CodeMode.execute({ code: "return 1", limits: { maxToolCalls: -1 } })).toThrow(RangeError)
expect(() => CodeMode.execute({ code: "return 1", limits: { maxOutputBytes: -1 } })).toThrow(RangeError)
expect(() => CodeMode.make({ tools, discovery: { catalogBudget: -1 } })).toThrow(RangeError)
const result = await Effect.runPromise(
CodeMode.make({
tools,
discovery: { catalogBudget: 0 },
}).execute(`return search({ query: "order", limit: 0.5 })`),
CodeMode.make({ tools }).execute(`return search({ query: "order", limit: 0.5 })`),
)
expect(result.ok).toBe(false)
if (result.ok) return
@@ -1226,7 +1063,7 @@ describe("CodeMode public contract", () => {
description: "Count invocations",
input: Schema.Struct({}),
output: Schema.Number,
run: () => Effect.succeed(1),
execute: () => Effect.succeed(1),
})
const result = await Effect.runPromise(
CodeMode.execute({
+1 -1
View File
@@ -13,7 +13,7 @@ const echo = (description: string) =>
description,
input: Schema.Struct({ value: Schema.String }),
output: Schema.String,
run: ({ value }) => Effect.succeed(value),
execute: ({ value }) => Effect.succeed(value),
})
const tools = {
+73 -76
View File
@@ -143,12 +143,7 @@ describe("OpenAPI.fromSpec", () => {
const remove = toolAt(api.tools, "users.remove")
expect(api.skipped).toEqual([])
if (
!Tool.isDefinition(get) ||
!Tool.isDefinition(create) ||
!Tool.isDefinition(search) ||
!Tool.isDefinition(remove)
) {
if (!Tool.isTool(get) || !Tool.isTool(create) || !Tool.isTool(search) || !Tool.isTool(remove)) {
throw new Error("happy-path fixture did not generate every operation")
}
expect(inputTypeScript(get)).toBe(
@@ -241,23 +236,23 @@ describe("OpenAPI.fromSpec", () => {
expect(toolAt(result.tools, "v2.session.create")).not.toBeUndefined()
const sessionGet = toolAt(result.tools, "v2.session.get")
expect(Tool.isDefinition(sessionGet)).toBe(true)
if (!Tool.isDefinition(sessionGet)) throw new Error("v2.session.get was not generated")
expect(Tool.isTool(sessionGet)).toBe(true)
if (!Tool.isTool(sessionGet)) throw new Error("v2.session.get was not generated")
expect(inputTypeScript(sessionGet)).toBe("{ sessionID: string }")
expect(outputTypeScript(sessionGet)).toContain("id: string")
expect(outputTypeScript(sessionGet)).toContain("additions: number")
const switchAgent = toolAt(result.tools, "v2.session.switchAgent")
expect(Tool.isDefinition(switchAgent)).toBe(true)
if (!Tool.isDefinition(switchAgent)) throw new Error("v2.session.switchAgent was not generated")
expect(Tool.isTool(switchAgent)).toBe(true)
if (!Tool.isTool(switchAgent)) throw new Error("v2.session.switchAgent was not generated")
expect(inputTypeScript(switchAgent)).toBe("{ sessionID: string; agent: string }")
const instructionPut = toolAt(result.tools, "v2.session.instructions.entry.put")
expect(Tool.isDefinition(instructionPut)).toBe(true)
if (!Tool.isDefinition(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated")
expect(Tool.isTool(instructionPut)).toBe(true)
if (!Tool.isTool(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated")
expect(inputTypeScript(instructionPut)).toBe("{ sessionID: string; key: string; value: unknown }")
expect(toolAt(result.tools, "v2_session_instructions_entry_put_2")).toBeUndefined()
expect(Tool.isDefinition(toolAt(result.tools, "v2.pty.connect"))).toBe(false)
expect(Tool.isTool(toolAt(result.tools, "v2.pty.connect"))).toBe(false)
expect(toolAt(result.tools, "v2.session.log")).toBeUndefined()
expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined()
expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined()
@@ -278,9 +273,9 @@ describe("OpenAPI.fromSpec", () => {
},
})
expect(Tool.isDefinition(toolAt(result.tools, "group.item"))).toBe(true)
expect(Tool.isDefinition(toolAt(result.tools, "group_item_2"))).toBe(true)
expect(Tool.isDefinition(toolAt(result.tools, "group.operation.other"))).toBe(true)
expect(Tool.isTool(toolAt(result.tools, "group.item"))).toBe(true)
expect(Tool.isTool(toolAt(result.tools, "group_item_2"))).toBe(true)
expect(Tool.isTool(toolAt(result.tools, "group.operation.other"))).toBe(true)
})
test("synthesizes flat operation IDs from methods and paths", () => {
@@ -305,7 +300,7 @@ describe("OpenAPI.fromSpec", () => {
"deleteUsersById",
"getOrganizationsByOrganizationidUsersById",
]) {
expect(Tool.isDefinition(toolAt(tools, path))).toBe(true)
expect(Tool.isTool(toolAt(tools, path))).toBe(true)
}
})
@@ -330,7 +325,7 @@ describe("OpenAPI.fromSpec", () => {
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ limit: number }")
})
@@ -358,8 +353,8 @@ describe("OpenAPI.fromSpec", () => {
})
const search = toolAt(result.tools, "search")
expect(Tool.isDefinition(search)).toBe(true)
if (!Tool.isDefinition(search)) throw new Error("search was not generated")
expect(Tool.isTool(search)).toBe(true)
if (!Tool.isTool(search)) throw new Error("search was not generated")
expect(inputTypeScript(search)).toBe("{ value?: string | null }")
const schema: unknown = search.input
const input = isRecord(schema) ? schema : {}
@@ -397,14 +392,14 @@ describe("OpenAPI.fromSpec", () => {
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.output)) throw new Error("test output was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.output)) throw new Error("test output was not generated")
expect(tool.output.$defs).toMatchObject({ Local: { type: "string" }, Global: { type: "number" } })
})
test("projects read-only and write-only properties by schema direction", () => {
for (const version of ["3.0.3", "3.1.0"]) {
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec(version) }).tools, "users.create")
if (!Tool.isDefinition(tool) || !isRecord(tool.input) || !isRecord(tool.output)) {
if (!Tool.isTool(tool) || !isRecord(tool.input) || !isRecord(tool.output)) {
throw new Error(`users.create was not generated for OpenAPI ${version}`)
}
@@ -467,7 +462,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ name: string }")
})
@@ -518,7 +513,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
const record = isRecord(properties.record) ? properties.record : {}
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
@@ -567,7 +562,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ name: string }")
})
@@ -607,7 +602,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
const node = isRecord(definitions.Node) ? definitions.Node : {}
@@ -648,7 +643,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
const leaf = isRecord(definitions[`C${depth - 1}`]) ? definitions[`C${depth - 1}`] : {}
@@ -686,7 +681,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ name: string }")
}
@@ -725,7 +720,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
const record: Record<string, unknown> = isRecord(properties.record) ? properties.record : {}
@@ -763,7 +758,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
const choice: Record<string, unknown> = isRecord(properties.choice) ? properties.choice : {}
const pick: Record<string, unknown> = isRecord(properties.pick) ? properties.pick : {}
@@ -807,7 +802,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
const record = isRecord(properties.record) ? properties.record : {}
@@ -836,7 +831,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ filter: { state: string } }")
})
@@ -866,7 +861,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ filter: { value: string } }")
})
@@ -901,7 +896,7 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
const body = isRecord(properties.body) ? properties.body : {}
const allOf = Array.isArray(body.allOf) ? body.allOf : []
@@ -923,11 +918,11 @@ describe("OpenAPI.fromSpec", () => {
}),
)
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec("3.1.0") }).tools, "users.create")
if (!Tool.isDefinition(tool)) throw new Error("users.create was not generated")
if (!Tool.isTool(tool)) throw new Error("users.create was not generated")
const result = await Effect.runPromise(
tool
.run({
.execute({
id: "ignored-top-level",
generated: "ignored-generated",
name: "Ada",
@@ -1022,10 +1017,12 @@ describe("OpenAPI.fromSpec", () => {
test("serializes deep-object query parameters from the opencode fixture", async () => {
const client = recordingClient(() => json({ directory: "/tmp" }))
const location = toolAt(OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools, "v2.location.get")
if (!Tool.isDefinition(location)) throw new Error("v2.location.get was not generated")
if (!Tool.isTool(location)) throw new Error("v2.location.get was not generated")
await Effect.runPromise(
location.run({ location: { directory: "/tmp", workspace: "workspace-1" } }).pipe(Effect.provide(client.layer)),
location
.execute({ location: { directory: "/tmp", workspace: "workspace-1" } })
.pipe(Effect.provide(client.layer)),
)
const url = new URL(client.requests[0]!.url)
@@ -1058,11 +1055,11 @@ describe("OpenAPI.fromSpec", () => {
},
})
const tool = toolAt(result.tools, "items")
if (!Tool.isDefinition(tool)) throw new Error("items was not generated")
if (!Tool.isTool(tool)) throw new Error("items was not generated")
await Effect.runPromise(
tool
.run({
.execute({
keys: ["a!", "b*"],
tags: ["x", "y"],
filter: { state: "open", page: 2 },
@@ -1081,9 +1078,9 @@ describe("OpenAPI.fromSpec", () => {
expect(url.searchParams.get("nullable")).toBe("null")
expect(url.searchParams.get("constructor")).toBe("safe")
expect(client.requests[0]!.headers.meta).toBe("a=b,c=d")
await expect(Effect.runPromise(tool.run({ keys: [undefined] }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
"unsupported nested value",
)
await expect(
Effect.runPromise(tool.execute({ keys: [undefined] }).pipe(Effect.provide(client.layer))),
).rejects.toThrow("unsupported nested value")
})
test("preserves ordered exploded and deep-object query parameters", async () => {
@@ -1101,11 +1098,11 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
await Effect.runPromise(
tool
.run({
.execute({
tags: ["first value", "second&value"],
filter: { state: "open now", page: 2 },
location: { directory: "/tmp/a b", workspace: "work&1" },
@@ -1116,14 +1113,14 @@ describe("OpenAPI.fromSpec", () => {
expect(client.requests[0]?.url).toBe(
`${baseUrl}/test?tags=first+value&tags=second%26value&state=open+now&page=2&location%5Bdirectory%5D=%2Ftmp%2Fa+b&location%5Bworkspace%5D=work%261`,
)
await expect(Effect.runPromise(tool.run({ tags: [{}] }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
await expect(Effect.runPromise(tool.execute({ tags: [{}] }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
"Parameter 'tags' contains an unsupported nested value.",
)
await expect(
Effect.runPromise(tool.run({ filter: { state: {} } }).pipe(Effect.provide(client.layer))),
Effect.runPromise(tool.execute({ filter: { state: {} } }).pipe(Effect.provide(client.layer))),
).rejects.toThrow("Query parameter 'filter' contains an unsupported nested value.")
await expect(
Effect.runPromise(tool.run({ location: { directory: [] } }).pipe(Effect.provide(client.layer))),
Effect.runPromise(tool.execute({ location: { directory: [] } }).pipe(Effect.provide(client.layer))),
).rejects.toThrow("Deep-object parameter 'location' contains an unsupported nested value.")
expect(client.requests).toHaveLength(1)
})
@@ -1203,9 +1200,9 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"getTest",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))
await Effect.runPromise(tool.execute({}).pipe(Effect.provide(client.layer)))
expect(inputTypeScript(tool)).toBe("{}")
expect(client.requests[0]!.headers.authorization).toBe("Bearer secret")
@@ -1240,9 +1237,9 @@ describe("OpenAPI.fromSpec", () => {
authenticated([{ key: [] }], { key: { type: "apiKey", in: "query", name: "__proto__" } }).tools,
"test",
)
if (!Tool.isDefinition(prototype)) throw new Error("prototype auth tool was not generated")
if (!Tool.isTool(prototype)) throw new Error("prototype auth tool was not generated")
await Effect.runPromise(prototype.run({}).pipe(Effect.provide(client.layer)))
await Effect.runPromise(prototype.execute({}).pipe(Effect.provide(client.layer)))
expect(new URL(client.requests[0]!.url).searchParams.get("__proto__")).toBe("secret")
const duplicate = toolAt(
@@ -1252,8 +1249,8 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(duplicate)) throw new Error("duplicate auth tool was not generated")
await expect(Effect.runPromise(duplicate.run({}).pipe(Effect.provide(client.layer)))).rejects.toThrow(
if (!Tool.isTool(duplicate)) throw new Error("duplicate auth tool was not generated")
await expect(Effect.runPromise(duplicate.execute({}).pipe(Effect.provide(client.layer)))).rejects.toThrow(
"multiple credentials",
)
@@ -1278,8 +1275,8 @@ describe("OpenAPI.fromSpec", () => {
},
})
const alternativeTool = toolAt(alternative.tools, "test")
if (!Tool.isDefinition(alternativeTool)) throw new Error("supported auth alternative was not generated")
await Effect.runPromise(alternativeTool.run({}).pipe(Effect.provide(client.layer)))
if (!Tool.isTool(alternativeTool)) throw new Error("supported auth alternative was not generated")
await Effect.runPromise(alternativeTool.execute({}).pipe(Effect.provide(client.layer)))
expect(client.requests.at(-1)?.headers.authorization).toBe("Bearer secret")
})
@@ -1290,9 +1287,9 @@ describe("OpenAPI.fromSpec", () => {
servers: [{ url: "https://document.example" }],
} satisfies Document
const tool = toolAt(OpenAPI.fromSpec({ spec }).tools, "test")
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))
await Effect.runPromise(tool.execute({}).pipe(Effect.provide(client.layer)))
expect(client.requests[0]?.url).toBe("https://operation.example/v1/test")
const invalid = OpenAPI.fromSpec({ spec, baseUrl: "https://example.com/api?tenant=one" })
@@ -1363,10 +1360,10 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
await expect(
Effect.runPromise(tool.run({ filter: { value: undefined } }).pipe(Effect.provide(client.layer))),
Effect.runPromise(tool.execute({ filter: { value: undefined } }).pipe(Effect.provide(client.layer))),
).rejects.toThrow("unsupported nested value")
expect(resolutions).toEqual([])
expect(client.requests).toEqual([])
@@ -1389,33 +1386,33 @@ describe("OpenAPI.fromSpec", () => {
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
await Effect.runPromise(tool.run({ body: { name: "updated" } }).pipe(Effect.provide(client.layer)))
await Effect.runPromise(tool.execute({ body: { name: "updated" } }).pipe(Effect.provide(client.layer)))
expect(client.requests[0]!.headers["content-type"]).toBe("application/merge-patch+json")
const cyclic: Record<string, unknown> = {}
cyclic.self = cyclic
await expect(Effect.runPromise(tool.run({ body: cyclic }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
await expect(Effect.runPromise(tool.execute({ body: cyclic }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
"Invalid JSON body",
)
})
test("rejects oversized and malformed JSON responses", async () => {
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: singleOperation({}) }).tools, "test")
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
const oversized = recordingClient(
() => new Response(null, { headers: { "content-length": String(50 * 1024 * 1024 + 1) } }),
)
const malformed = recordingClient(() => new Response("{", { headers: { "content-type": "application/json" } }))
const chunked = recordingClient(() => new Response(new Uint8Array(50 * 1024 * 1024 + 1)))
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(oversized.layer)))).rejects.toThrow(
await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(oversized.layer)))).rejects.toThrow(
"response exceeds 50 MiB",
)
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(malformed.layer)))).rejects.toThrow(
await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(malformed.layer)))).rejects.toThrow(
"returned malformed JSON",
)
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(chunked.layer)))).rejects.toThrow(
await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(chunked.layer)))).rejects.toThrow(
"response exceeds 50 MiB",
)
})
@@ -1428,11 +1425,11 @@ describe("OpenAPI.fromSpec", () => {
},
})
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec }).tools, "test")
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
if (!Tool.isTool(tool)) throw new Error("test was not generated")
const client = recordingClient(() => new Response("123", { headers: { "content-type": "text/plain" } }))
expect(outputTypeScript(tool)).toBe("string | null")
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))).resolves.toBe("123")
await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(client.layer)))).resolves.toBe("123")
})
test("fails missing required parameters before auth and network", async () => {
@@ -1497,13 +1494,13 @@ describe("OpenAPI.fromSpec", () => {
const update = toolAt(tools, "things.update")
const echo = toolAt(tools, "echo")
expect(Tool.isDefinition(update)).toBe(true)
if (!Tool.isDefinition(update)) throw new Error("things.update was not generated")
expect(Tool.isTool(update)).toBe(true)
if (!Tool.isTool(update)) throw new Error("things.update was not generated")
expect(inputTypeScript(update)).toBe(
"{ path_id: string; query_id: string; path_id_2?: string; header_id: string; body_id: string }",
)
expect(Tool.isDefinition(echo)).toBe(true)
if (!Tool.isDefinition(echo)) throw new Error("echo was not generated")
expect(Tool.isTool(echo)).toBe(true)
if (!Tool.isTool(echo)) throw new Error("echo was not generated")
expect(inputTypeScript(echo)).toBe("{ body: string }")
const runtime = CodeMode.make({ tools })
@@ -1584,13 +1581,13 @@ describe("OpenAPI.fromSpec", () => {
for (const name of ["optional", "dictionary", "composed", "nullable"]) {
const tool = toolAt(tools, `body.${name}`)
expect(Tool.isDefinition(tool)).toBe(true)
if (!Tool.isDefinition(tool)) throw new Error(`body.${name} was not generated`)
expect(Tool.isTool(tool)).toBe(true)
if (!Tool.isTool(tool)) throw new Error(`body.${name} was not generated`)
const input = isRecord(tool.input) ? tool.input : {}
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual(["body"])
}
const optional = toolAt(tools, "body.optional")
if (!Tool.isDefinition(optional)) throw new Error("body.optional was not generated")
if (!Tool.isTool(optional)) throw new Error("body.optional was not generated")
expect(inputTypeScript(optional)).toBe("{ body?: { name: string } }")
})
})
+8 -8
View File
@@ -33,7 +33,7 @@ const echoTool = (trace: Trace) =>
description: "Echo an id immediately",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Number,
run: ({ id }) =>
execute: ({ id }) =>
Effect.sync(() => {
trace.starts.push(id)
trace.completed += 1
@@ -46,7 +46,7 @@ const gatedTool = (trace: Trace, gate: (id: number) => Deferred.Deferred<void>)
description: "Echo an id once its gate opens",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Number,
run: ({ id }) =>
execute: ({ id }) =>
Effect.gen(function* () {
trace.starts.push(id)
trace.active += 1
@@ -70,7 +70,7 @@ const openTool = (gate: (id: number) => Deferred.Deferred<void>) =>
description: "Open the gate for an id",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Boolean,
run: ({ id }) => Deferred.succeed(gate(id), undefined),
execute: ({ id }) => Deferred.succeed(gate(id), undefined),
})
const pendingTool = (trace: Trace) =>
@@ -78,7 +78,7 @@ const pendingTool = (trace: Trace) =>
description: "Never settle",
input: Schema.Struct({ id: Schema.Number }),
output: Schema.Number,
run: ({ id }) =>
execute: ({ id }) =>
Effect.gen(function* () {
trace.starts.push(id)
trace.active += 1
@@ -98,14 +98,14 @@ const failingTool = Tool.make({
description: "Always refuse",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.fail(toolError("Lookup refused")),
execute: () => Effect.fail(toolError("Lookup refused")),
})
const interruptedTool = Tool.make({
description: "Interrupt this call",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.interrupt,
execute: () => Effect.interrupt,
})
const completedTool = (trace: Trace) =>
@@ -113,7 +113,7 @@ const completedTool = (trace: Trace) =>
description: "Return the number of completed calls",
input: Schema.Struct({}),
output: Schema.Number,
run: () => Effect.succeed(trace.completed),
execute: () => Effect.succeed(trace.completed),
})
/** Never settles, and holds interruption cleanup for `cleanupMs` so completion cleanup can outlast a timeout. */
@@ -122,7 +122,7 @@ const stubbornTool = (trace: Trace) =>
description: "Never settle; clean up slowly when interrupted",
input: Schema.Struct({ cleanupMs: Schema.Number }),
output: Schema.Number,
run: ({ cleanupMs }) =>
execute: ({ cleanupMs }) =>
Effect.never.pipe(
Effect.onInterrupt(() =>
Effect.andThen(
+15 -19
View File
@@ -18,7 +18,8 @@ const listIssues = Tool.make({
},
required: ["owner"],
},
run: () => Effect.succeed("[]"),
output: {},
execute: () => Effect.succeed("[]"),
})
// An Effect Schema tool whose field annotations must flow through the emitted JSON Schema.
@@ -31,7 +32,7 @@ const lookupOrder = Tool.make({
output: Schema.Struct({
status: Schema.String.annotate({ description: "Current order status" }),
}),
run: () => Effect.succeed({ status: "open" }),
execute: () => Effect.succeed({ status: "open" }),
})
describe("pretty signature rendering", () => {
@@ -261,7 +262,7 @@ describe("non-identifier property names render as quoted keys", () => {
properties: { "content-type": { type: "string" } },
required: ["content-type"],
} as const,
run: () => Effect.succeed({ "content-type": "text/plain" }),
execute: () => Effect.succeed({ "content-type": "text/plain" }),
})
expect(inputTypeScript(tool)).toContain('"foo-bar"?: string')
expect(outputTypeScript(tool)).toBe('{ "content-type": string }')
@@ -272,7 +273,7 @@ describe("non-identifier property names render as quoted keys", () => {
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),
execute: () => Effect.succeed(null),
})
expect(inputTypeScript(tool)).toBe('{ "foo-bar": string; plain?: number }')
expect(inputTypeScript(tool, true)).toBe(["{", ' "foo-bar": string,', " plain?: number,", "}"].join("\n"))
@@ -306,7 +307,7 @@ describe("union schemas render every alternative", () => {
},
} as const,
output: { anyOf: [{ type: "number" }, { type: "boolean" }] } as const,
run: () => Effect.succeed(1),
execute: () => Effect.succeed(1),
})
expect(inputTypeScript(tool)).toBe("{ value?: string | number }")
expect(outputTypeScript(tool)).toBe("number | boolean")
@@ -394,15 +395,15 @@ describe("JSDoc signatures in catalogs and search results", () => {
}
})
test("the inline catalog uses the same JSDoc signatures", async () => {
const instructions = runtime.instructions()
test("the catalog uses the same JSDoc signatures as search", async () => {
const catalog = runtime.catalog()
const github = (await search("list issues repository")).items.find(
({ path }) => path === "tools.github.list_issues",
)!
const orders = (await search("look up order")).items.find(({ path }) => path === "tools.orders.lookup")!
expect(instructions).toContain(` - ${github.signature} // List issues in a repository`)
expect(instructions).toContain(` - ${orders.signature} // Look up an order`)
expect(instructions).toContain("/** Repository owner */")
expect(catalog.map(({ signature }) => signature)).toContain(github.signature)
expect(catalog.map(({ signature }) => signature)).toContain(orders.signature)
expect(github.signature).toContain("/** Repository owner */")
})
})
@@ -417,20 +418,15 @@ describe("non-identifier tool paths", () => {
},
required: ["query", "libraryName"],
} as const,
run: () => Effect.succeed("/reactjs/react.dev"),
output: {},
execute: () => Effect.succeed("/reactjs/react.dev"),
})
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
test("inline catalog uses bracket notation for dashed tool names", () => {
const instructions = runtime.instructions()
expect(instructions).toContain(
test("catalog signatures use bracket notation for dashed tool names", () => {
expect(runtime.catalog()[0]?.signature).toBe(
'tools.context7["resolve-library-id"](input: {\n query: string,\n libraryName: string,\n}): Promise<unknown>',
)
expect(instructions).toContain("Do not infer or normalize tool names")
expect(instructions).toContain("bracket notation and quotes are part of the path")
expect(instructions).not.toContain("tools.context7.resolve-library-id")
expect(instructions).not.toContain("tools.context7.resolve_library_id")
})
test("search results return callable bracket-notation paths and signatures", async () => {
+2 -2
View File
@@ -329,7 +329,7 @@ describe("RegExp", () => {
description: "Decorate a string",
input: Schema.String,
output: Schema.String,
run: (input) => Effect.succeed(`[${input}]`),
execute: (input) => Effect.succeed(`[${input}]`),
})
const result = await Effect.runPromise(
CodeMode.execute({
@@ -1028,7 +1028,7 @@ describe("CodeMode values at intra-CodeMode checkpoints", () => {
const capture = Tool.make({
description: "Capture the exact input the host receives",
input: { type: "object" },
run: (input) =>
execute: (input) =>
Effect.sync(() => {
observed.push(input)
return "ok"
+33 -6
View File
@@ -7,7 +7,7 @@ const echo = (description: string, result: string) =>
description,
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.succeed(result),
execute: () => Effect.succeed(result),
})
const value = async (runtime: CodeMode.Runtime, code: string) => {
@@ -30,7 +30,6 @@ describe("dotted tool names", () => {
expect(catalog).toHaveLength(1)
expect(catalog[0]?.path).toBe("api.issues.list")
expect(catalog[0]?.signature).toStartWith("tools.api.issues.list(input:")
expect(runtime.instructions()).toContain("tools.api.issues.list(input:")
})
test("the advertised dotted path is executable", async () => {
@@ -86,9 +85,12 @@ describe("callable namespaces", () => {
const diagnostic = await failure(runtime, `return await tools.issues.missing({})`)
expect(diagnostic.kind).toBe("UnknownTool")
expect(diagnostic.message).toContain("Unknown tool 'issues.missing'")
expect(diagnostic.suggestions).toEqual([
"The tool may have been removed or renamed. Use search to find available tools.",
])
})
test("a namespace without its own definition stays non-callable", async () => {
test("a namespace without its own tool stays non-callable", async () => {
const nested = CodeMode.make({ tools: { "issues.list": echo("List issues", "list") } })
const diagnostic = await failure(nested, `return await tools.issues({})`)
expect(diagnostic.kind).toBe("UnknownTool")
@@ -96,6 +98,31 @@ describe("callable namespaces", () => {
})
})
describe("tool input diagnostics", () => {
const runtime = CodeMode.make({
tools: {
"notes.echo": Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.String,
execute: ({ text }) => Effect.succeed(text),
}),
},
})
test("a schema mismatch suggests searching for the current signature", async () => {
const diagnostic = await failure(runtime, `return await tools.notes.echo({ message: "hello" })`)
expect(diagnostic.kind).toBe("InvalidToolInput")
expect(diagnostic.suggestions).toEqual(["The signature may have changed. Use search to get the current signature."])
})
test("a wrong argument count keeps the existing error without a stale-signature hint", async () => {
const diagnostic = await failure(runtime, `return await tools.notes.echo()`)
expect(diagnostic.kind).toBe("InvalidToolInput")
expect(diagnostic.suggestions).toBeUndefined()
})
})
describe("blocked member names on tool paths", () => {
const runtime = CodeMode.make({
tools: {
@@ -114,9 +141,9 @@ describe("blocked member names on tool paths", () => {
expect(await value(runtime, `return Object.keys(tools.issues)`)).toEqual(["constructor"])
})
test("a literal __proto__ key cannot poison a namespace into a fake definition", async () => {
test("a literal __proto__ key cannot poison a namespace into a fake tool", async () => {
const poisoned = CodeMode.make({
tools: { ns: { "__proto__": echo("Hidden", "hidden"), real: echo("Real tool", "real") } },
tools: { ns: { __proto__: echo("Hidden", "hidden"), real: echo("Real tool", "real") } },
})
expect(poisoned.catalog().map((tool) => tool.path)).toEqual(["ns.real"])
expect(await value(poisoned, `return await tools.ns.real({})`)).toBe("real")
@@ -138,7 +165,7 @@ describe("empty segments", () => {
})
describe("canonical path collisions", () => {
test("the last definition supplied for a canonical path wins", async () => {
test("the last tool supplied for a canonical path wins", async () => {
const runtime = CodeMode.make({
tools: { "issues.list": echo("First", "first"), issues: { list: echo("Second", "second") } },
})
+2 -2
View File
@@ -77,7 +77,7 @@
"@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/mistral": "3.0.51",
"@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
@@ -91,7 +91,7 @@
"@effect/sql-sqlite-bun": "catalog:",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
"@ff-labs/fff-bun": "0.9.4",
"@ff-labs/fff-bun": "0.10.1",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
-7
View File
@@ -422,7 +422,6 @@ function callOptions(request: LLMRequest): LanguageModelV3CallOptions {
presencePenalty: request.generation?.presencePenalty,
frequencyPenalty: request.generation?.frequencyPenalty,
seed: request.generation?.seed,
responseFormat: responseFormat(request),
tools: request.tools.map(tool),
toolChoice: toolChoice(request.toolChoice),
headers: request.http?.headers,
@@ -527,12 +526,6 @@ function toolChoice(input: LLMRequest["toolChoice"]): LanguageModelV3ToolChoice
return { type: input.type }
}
function responseFormat(request: LLMRequest): LanguageModelV3CallOptions["responseFormat"] {
if (request.responseFormat?.type === "json")
return { type: "json", schema: request.responseFormat.schema as JSONSchema7 }
if (request.responseFormat) return { type: "text" }
}
function providerOptions(input: LLMRequest["providerOptions"]): SharedV3ProviderOptions | undefined {
if (!input) return undefined
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)]))
+21 -23
View File
@@ -1,22 +1,21 @@
export * as CodeMode from "./codemode"
import { Context, Effect, Layer, Scope } from "effect"
import { CodeModeCatalog } from "./codemode/catalog"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { PermissionV2 } from "./permission"
import { ExecuteTool } from "./tool/execute"
import { permission, registrationEntries, type AnyTool } from "./tool/tool"
import { Tools } from "./tool/tools"
import type { Any, Registration } from "./tool/tool"
import { Wildcard } from "./util/wildcard"
export interface Materialization {
readonly tool?: AnyTool
readonly instructions?: string
readonly tool?: Any
readonly catalog?: ReadonlyArray<CodeModeCatalog.Entry>
}
export interface Interface {
readonly register: (
tools: Readonly<Record<string, AnyTool>>,
options?: Tools.RegisterOptions,
registrations: ReadonlyArray<Registration & { readonly key: string }>,
) => Effect.Effect<void, never, Scope.Scope>
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
}
@@ -26,29 +25,28 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const local = new Map<
string,
Array<{ readonly token: object; readonly registration: ExecuteTool.Registration }>
>()
const local = new Map<string, Array<{ readonly token: object; readonly registration: ExecuteTool.Registration }>>()
return Service.of({
register: Effect.fn("CodeMode.register")(function* (tools, options) {
const entries = registrationEntries(tools, options?.namespace)
if (entries.length === 0) return
register: Effect.fn("CodeMode.register")(function* (registrations) {
if (registrations.length === 0) return
yield* Effect.uninterruptible(
Effect.gen(function* () {
const token = {}
for (const entry of entries)
local.set(entry.key, [
...(local.get(entry.key) ?? []),
{ token, registration: { tool: entry.tool, name: entry.name, namespace: entry.namespace } },
for (const registration of registrations)
local.set(registration.key, [
...(local.get(registration.key) ?? []),
{
token,
registration,
},
])
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const entry of entries) {
const registrations = local.get(entry.key)?.filter((item) => item.token !== token) ?? []
if (registrations.length > 0) local.set(entry.key, registrations)
else local.delete(entry.key)
for (const registration of registrations) {
const remaining = local.get(registration.key)?.filter((item) => item.token !== token) ?? []
if (remaining.length > 0) local.set(registration.key, remaining)
else local.delete(registration.key)
}
}),
)
@@ -61,7 +59,7 @@ const layer = Layer.effect(
for (const [name, entries] of local) {
const registration = entries.at(-1)?.registration
if (!registration) continue
const rule = rules.findLast((rule) => Wildcard.match(permission(registration.tool, name), rule.action))
const rule = rules.findLast((rule) => Wildcard.match(registration.permission, rule.action))
if (rule?.resource === "*" && rule.effect === "deny") continue
registrations.set(name, registration)
}
@@ -70,7 +68,7 @@ const layer = Layer.effect(
if (executeRule?.resource === "*" && executeRule.effect === "deny") return {}
return {
tool: ExecuteTool.create(registrations),
instructions: ExecuteTool.instructions(registrations),
catalog: ExecuteTool.catalog(registrations),
}
}),
})
+103
View File
@@ -0,0 +1,103 @@
export * as CodeModeCatalog from "./catalog"
import { Schema } from "effect"
export const Entry = Schema.Struct({
path: Schema.String,
description: Schema.String,
signature: Schema.String,
})
export type Entry = typeof Entry.Type
const Listing = Schema.Struct({
path: Schema.String,
line: Schema.String,
})
const Namespace = Schema.Struct({
name: Schema.String,
count: Schema.Number,
entries: Schema.Array(Listing),
})
export const Summary = Schema.Struct({
total: Schema.Number,
shown: Schema.Number,
namespaces: Schema.Array(Namespace),
})
export type Summary = typeof Summary.Type
const DESCRIPTION_LIMIT = 120
const CHARACTERS_PER_TOKEN = 4
const INLINE_BUDGET = 2_000
// Keep every namespace searchable, then select full listings one per namespace per round,
// considering shorter listings first until the inline budget is exhausted.
export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET): Summary {
const namespaces = [...Map.groupBy(entries, (entry) => entry.path.split(".", 1)[0] ?? entry.path)]
.sort(([left], [right]) => {
if (left < right) return -1
if (left > right) return 1
return 0
})
.map(([name, namespaceEntries]) => {
const listings = namespaceEntries
.map((entry) => {
const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? ""
const description =
firstLine.length > DESCRIPTION_LIMIT
? firstLine.slice(0, DESCRIPTION_LIMIT - 3) + "..."
: firstLine
const suffix = description.length === 0 ? "" : ` // ${description}`
return { path: entry.path, line: ` - ${entry.signature}${suffix}` }
})
.toSorted((left, right) => {
if (left.path < right.path) return -1
if (left.path > right.path) return 1
return 0
})
return {
name,
listings,
selectionOrder: rankListings(listings),
selectedListings: new Set<typeof Listing.Type>(),
}
})
const active = new Set(namespaces)
let remaining = budget
while (active.size > 0) {
for (const namespace of active) {
const candidate = namespace.selectionOrder[namespace.selectedListings.size]
if (!candidate || candidate.cost > remaining) {
active.delete(namespace)
continue
}
namespace.selectedListings.add(candidate.listing)
remaining -= candidate.cost
if (namespace.selectedListings.size === namespace.selectionOrder.length) active.delete(namespace)
}
}
const namespaceSummaries = namespaces.map((namespace) => ({
name: namespace.name,
count: namespace.listings.length,
entries: namespace.listings.filter((listing) => namespace.selectedListings.has(listing)),
}))
return {
total: entries.length,
shown: namespaceSummaries.reduce((total, namespace) => total + namespace.entries.length, 0),
namespaces: namespaceSummaries,
}
}
function rankListings(listings: ReadonlyArray<typeof Listing.Type>) {
return listings
.map((listing) => ({ listing, cost: Math.round(listing.line.length / CHARACTERS_PER_TOKEN) }))
.toSorted((left, right) => {
if (left.cost !== right.cost) return left.cost - right.cost
if (left.listing.path < right.listing.path) return -1
if (left.listing.path > right.listing.path) return 1
return 0
})
}
+129 -15
View File
@@ -1,10 +1,130 @@
export * as CodeModeInstructions from "./instructions"
import { Context, Effect, Layer, Schema } from "effect"
import { searchSignature, toolExpression } from "@opencode-ai/codemode"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { AgentV2 } from "../agent"
import { CodeMode } from "../codemode"
import { Instructions } from "../instructions/index"
import { CodeModeCatalog } from "./catalog"
// prettier-ignore
const prompt = (hasMoreTools: boolean) => `Run JavaScript to orchestrate tool calls and compose their results. Imports, filesystem access, and timers are unavailable. Do not use \`fetch\`; all API calls go through \`tools\`.
Prefer an explicit \`return\`; if omitted, the final top-level expression becomes the result. Await tool calls before returning; any calls still pending when execution ends are interrupted. Run independent calls concurrently with \`Promise.all\`.
Do not infer or normalize tool names; use only the exact signatures shown below${hasMoreTools ? " or returned by `search`" : ""}, preserving bracket notation such as \`tools.<namespace>["tool-name"](input)\`.${hasMoreTools ? `
## Search
Only some tool signatures are shown. Use \`search\` to discover exact paths and signatures for additional tools:
- ${searchSignature}` : ""}
## Available tools`
export function render(catalog: CodeModeCatalog.Summary) {
if (catalog.total === 0) return "No tools are currently available."
const tools = catalog.namespaces.flatMap((namespace) => {
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
const label =
namespace.entries.length === namespace.count
? count
: namespace.entries.length === 0
? `${count}, none shown`
: `${count}, ${namespace.entries.length} shown`
return [`- ${namespace.name} (${label})`, ...namespace.entries.map((entry) => entry.line)]
})
return `${prompt(catalog.shown < catalog.total)}
${tools.join("\n")}`
}
export function update(previous: CodeModeCatalog.Summary, current: CodeModeCatalog.Summary) {
const full = `The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.
${render(current)}`
const previousComplete = previous.shown === previous.total
const currentComplete = current.shown === current.total
if (previousComplete !== currentComplete) return full
const diff = Instructions.diffByKey(
previous.namespaces.flatMap((namespace) => namespace.entries),
current.namespaces.flatMap((namespace) => namespace.entries),
(entry) => entry.path,
(before, after) => before.line !== after.line,
)
const entriesChanged = diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0
if (!currentComplete) {
if (entriesChanged) return full
const namespaces = Instructions.diffByKey(
previous.namespaces,
current.namespaces,
(namespace) => namespace.name,
(before, after) => before.count !== after.count,
)
const changed = namespaces.added.length > 0 || namespaces.removed.length > 0 || namespaces.changed.length > 0
if (!changed) return full
const parts = ["The Code Mode tool catalog has changed."]
if (namespaces.added.length > 0) {
parts.push(
`New tool namespaces are available: ${namespaces.added
.map((namespace) => `\`${namespace.name}\` (${namespace.count} tools)`)
.join(", ")}.`,
)
}
if (namespaces.changed.length > 0) {
parts.push(
`The following namespace inventories changed; search them again before relying on previous results: ${namespaces.changed
.map((change) => `\`${change.current.name}\` now has ${change.current.count} tools`)
.join(", ")}.`,
)
}
if (namespaces.removed.length > 0) {
parts.push(
`The following tool namespaces are no longer available and must not be used: ${namespaces.removed
.map((namespace) => `\`${namespace.name}\``)
.join(", ")}.`,
)
}
const delta = parts.join("\n\n")
if (delta.length < full.length) return delta
return full
}
if (!entriesChanged) return full
const parts = ["The Code Mode tool catalog has changed."]
if (diff.added.length > 0) {
parts.push(
[
"New tools are available in addition to those previously listed:",
...diff.added.map((entry) => entry.line),
].join("\n"),
)
}
if (diff.changed.length > 0) {
parts.push(
[
"Changed tool listings supersede the previously listed ones:",
...diff.changed.map((change) => change.current.line),
].join("\n"),
)
}
if (diff.removed.length > 0) {
parts.push(
`The following tools are no longer available and must not be called: ${diff.removed
.map((entry) => toolExpression(entry.path))
.join(", ")}.`,
)
}
const delta = parts.join("\n\n")
if (delta.length < full.length) return delta
return full
}
export interface Interface {
readonly load: (agent: AgentV2.Selection) => Effect.Effect<Instructions.Instructions>
@@ -19,22 +139,16 @@ const layer = Layer.effect(
return Service.of({
load: Effect.fn("CodeModeInstructions.load")(function* (selection) {
const instructions = selection.info
? (yield* codeMode.materialize(selection.info.permissions)).instructions
: undefined
return Instructions.make({
const entries = selection.info ? ((yield* codeMode.materialize(selection.info.permissions)).catalog ?? []) : []
const catalog = CodeModeCatalog.summarize(entries)
return Instructions.make<CodeModeCatalog.Summary>({
key: Instructions.Key.make("core/codemode"),
codec: Schema.toCodecJson(Schema.String),
read: Effect.succeed(instructions ?? Instructions.removed),
codec: Schema.toCodecJson(CodeModeCatalog.Summary),
read: Effect.succeed(catalog.total === 0 ? Instructions.removed : catalog),
render: {
initial: (current) => current,
changed: (_previous, current) =>
[
"The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.",
current,
].join("\n\n"),
removed: () =>
"Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.",
initial: render,
changed: update,
removed: () => "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.",
},
})
}),
+1
View File
@@ -56,5 +56,6 @@ export const migrations = (
import("./migration/20260710025429_instruction_sync"),
import("./migration/20260716020354_kv"),
import("./migration/20260722011141_delete_tool_progress_events"),
import("./migration/20260722170000_canonical_tool_results"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,123 @@
import { sql } from "drizzle-orm"
import { Effect, Schema } from "effect"
import type { DatabaseMigration } from "../migration"
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const isObject = Schema.is(Schema.Record(Schema.String, Schema.Unknown))
const isJsonObject = Schema.is(Schema.Record(Schema.String, Schema.Json))
const object = (value: unknown): Record<string, unknown> => (isObject(value) ? value : {})
const stringify = (value: unknown) => {
try {
return JSON.stringify(value, null, 2) ?? String(value)
} catch {
return String(value)
}
}
const contentOf = (state: Record<string, unknown>) => (Array.isArray(state.content) ? state.content : [])
const resultOf = (state: Record<string, unknown>) =>
isObject(state.result) && "value" in state.result ? state.result.value : state.result
const metadataOf = (state: Record<string, unknown>) => {
if (isJsonObject(state.structured) && Object.keys(state.structured).length > 0)
return { metadata: state.structured }
return isJsonObject(state.metadata) ? { metadata: state.metadata } : {}
}
const completedContent = (state: Record<string, unknown>) => {
const preserved = contentOf(state)
if (preserved.length > 0) return preserved
return [{ type: "text", text: stringify(Object.keys(object(state.structured)).length ? state.structured : resultOf(state)) }]
}
/**
* One-time rewrite of projected tool rows into the canonical result shape:
* terminal states store model content plus optional metadata; the generic
* `structured` and `result` fields disappear. Provider-hosted result payloads
* move into provider-owned result state so hosted continuation survives.
* Pre-release durable event versions are intentionally left untouched.
*/
export default {
id: "20260722170000_canonical_tool_results",
up(tx) {
return Effect.gen(function* () {
// Keyset-paginated batches keep memory bounded: production databases hold
// gigabytes of assistant rows, and materializing them all at once was
// measured at a ~5GB RSS spike.
let cursor = ""
while (true) {
const messages = yield* tx.all<{ id: string; data: string }>(
sql`SELECT id, data FROM session_message WHERE type = 'assistant' AND id > ${cursor} ORDER BY id LIMIT 1000`,
)
if (messages.length === 0) break
cursor = messages[messages.length - 1].id
yield* rewrite(tx, messages)
}
})
},
} satisfies DatabaseMigration.Migration
function rewrite(tx: Parameters<DatabaseMigration.Migration["up"]>[0], messages: { id: string; data: string }[]) {
return Effect.gen(function* () {
for (const row of messages) {
// A row that never decoded is skipped rather than failing the whole
// migration on every startup; it was equally unreadable before.
const decoded = decodeJson(row.data)
if (decoded._tag === "None") {
yield* Effect.logWarning("skipping undecodable session_message row").pipe(Effect.annotateLogs({ id: row.id }))
continue
}
const data = object(decoded.value)
if (!Array.isArray(data.content)) continue
let changed = false
const content = data.content.map((part) => {
const tool = object(part)
if (tool.type !== "tool" || !isObject(tool.state)) return part
const state = tool.state
if (state.status !== "completed" && state.status !== "error" && state.status !== "running") return part
if (!("structured" in state) && !("result" in state)) return part
changed = true
if (state.status === "running")
return {
...tool,
state: {
status: "running",
input: object(state.input),
metadata: object(state.structured),
},
}
// Hosted payloads are irreducible provider replay state; keep them under
// the provider-owned result state instead of a generic result field.
const hosted =
tool.executed === true && isObject(state.result) && "value" in state.result
? { providerResultState: { ...object(tool.providerResultState), result: state.result.value } }
: {}
const preserved = contentOf(state)
if (state.status === "completed")
return {
...tool,
...hosted,
state: {
status: "completed",
input: object(state.input),
content: completedContent(state),
...metadataOf(state),
},
}
return {
...tool,
...hosted,
state: {
status: "error",
input: object(state.input),
error: state.error,
...(preserved.length > 0 ? { content: preserved } : {}),
...metadataOf(state),
},
}
})
if (!changed) continue
yield* tx.run(sql`UPDATE session_message SET data = ${JSON.stringify({ ...data, content })} WHERE id = ${row.id}`)
}
})
}
+29 -13
View File
@@ -127,8 +127,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
),
},
model: {
get: (providerID, modelID) =>
catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
get: (providerID, modelID) => catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
list: () => response(catalog.model.available()),
default: () => response(catalog.model.default()),
},
@@ -358,7 +357,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
Effect.gen(function* () {
const registrations: Array<{
readonly name: string
readonly tool: Tool.AnyTool
readonly tool: Tool.Any
readonly options?: Tool.RegisterOptions
}> = []
yield* Effect.sync(() =>
@@ -395,25 +394,42 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
})
}
return toolHooks.hook.after((event) => {
const output = {
// JS plugin boundary: marshal the canonical outcome out, copy mutations back.
const output: Record<string, unknown> = {
tool: event.tool,
sessionID: event.sessionID,
agent: event.agent,
messageID: event.messageID,
callID: event.callID,
input: event.input,
result: event.result,
output: event.output,
status: event.status,
content: event.content,
metadata: event.metadata,
outputPaths: event.outputPaths,
...(event.status === "error" ? { error: event.error } : {}),
}
return Reflect.apply(callback, undefined, [output]).pipe(
Effect.tap(() =>
Effect.sync(() => {
event.result = output.result
event.output = output.output
event.outputPaths = output.outputPaths
}),
),
Effect.tap(() => {
const decoded = Schema.decodeUnknownOption(Tool.ExecuteAfterOutcome)(output)
if (decoded._tag === "None")
return Effect.logWarning("ignoring invalid execute.after tool outcome", { tool: event.tool })
if (decoded.value.status !== event.status)
return Effect.logWarning("ignoring execute.after tool status change", { tool: event.tool })
return Effect.sync(() => {
if (event.status === "completed" && decoded.value.status === "completed") {
if (output.content !== event.content) event.content = decoded.value.content
if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata
if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths
return
}
if (event.status === "error" && decoded.value.status === "error") {
if (output.error !== event.error) event.error = decoded.value.error
if (output.content !== event.content) event.content = decoded.value.content
if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata
if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths
}
})
}),
)
})
},
+6 -16
View File
@@ -2,7 +2,7 @@ export * as PluginPromise from "./promise"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import type { Context, Plugin } from "@opencode-ai/plugin/v2/plugin"
import type { AnyTool } from "@opencode-ai/plugin/v2/tool"
import type { Any, RegisterOptions } from "@opencode-ai/plugin/v2/tool"
import { Agent } from "@opencode-ai/schema/agent"
import { Integration } from "@opencode-ai/schema/integration"
import { Location } from "@opencode-ai/schema/location"
@@ -189,7 +189,8 @@ export function fromPromise(plugin: Plugin) {
register(
host.tool.transform((draft) =>
callback({
add: (tool: AnyTool) => draft.add(tool.name, fromPromiseTool(tool), tool.options),
add: (name: string, tool: Any, options?: RegisterOptions) =>
draft.add(name, fromPromiseTool(tool), options),
}),
),
),
@@ -302,19 +303,8 @@ function wireEvent(value: unknown): unknown {
return wire(value)
}
function fromPromiseTool(tool: AnyTool) {
if ("jsonSchema" in tool)
return Tool.make({
...tool,
execute: (input, context) =>
Effect.promise(() =>
tool.execute(input, {
...context,
progress: (update) => Effect.runPromise(context.progress(update)),
}),
),
})
return Tool.make({
function fromPromiseTool(tool: Any): Tool.Any {
return {
...tool,
execute: (input, context) =>
Effect.promise(() =>
@@ -323,5 +313,5 @@ function fromPromiseTool(tool: AnyTool) {
progress: (update) => Effect.runPromise(context.progress(update)),
}),
),
})
}
}
+6 -4
View File
@@ -36,8 +36,8 @@ export const layer = Layer.effect(
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(selection.session.id)
? selection.session.id.slice(4)
: selection.session.id
const executableTools = yield* registry.materialize(selection.agent.info.permissions)
const toolDefinitions = executableTools.definitions
const toolSet = yield* registry.snapshot(selection.agent.info.permissions)
const toolDefinitions = toolSet.definitions
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
const contextEvent = yield* hooks.trigger("session", "context", {
sessionID: selection.session.id,
@@ -52,7 +52,10 @@ export const layer = Layer.effect(
Message.user(input.prompt),
],
tools: Object.fromEntries(
toolDefinitions.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
toolDefinitions.map((tool) => [
tool.name,
{ description: tool.description, input: { ...tool.inputSchema } },
]),
),
})
const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
@@ -74,7 +77,6 @@ export const layer = Layer.effect(
system: contextEvent.system,
messages: contextEvent.messages,
tools: hookedTools,
toolChoice: "none",
}),
)
yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
+8 -10
View File
@@ -355,8 +355,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
SessionMessage.ToolStateRunning.make({
status: "running",
input: event.data.input,
structured: {},
content: [],
metadata: {},
}),
)
}
@@ -366,11 +365,12 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") {
match.state.structured = event.data.structured
match.state.content = [...event.data.content]
match.state.metadata = event.data.metadata
}
})
},
// Terminal tool events are self-contained; projection is a direct copy and
// never reaches into ephemeral progress history.
"session.tool.success": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
@@ -382,9 +382,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
SessionMessage.ToolStateCompleted.make({
status: "completed",
input: match.state.input,
structured: event.data.structured,
content: [...event.data.content],
result: event.data.result,
content: event.data.content,
...(event.data.metadata === undefined ? {} : { metadata: event.data.metadata }),
}),
)
}
@@ -402,9 +401,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
status: "error",
error: event.data.error,
input: typeof match.state.input === "string" ? {} : match.state.input,
structured: event.data.metadata ?? (match.state.status === "running" ? match.state.structured : {}),
content: event.data.content ?? (match.state.status === "running" ? match.state.content : []),
result: event.data.result,
...(event.data.content === undefined ? {} : { content: event.data.content }),
...(event.data.metadata === undefined ? {} : { metadata: event.data.metadata }),
}),
)
}
+24 -19
View File
@@ -14,13 +14,15 @@ import { MAX_STEPS_PROMPT } from "./runner/max-steps"
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { toLLMMessages } from "./runner/to-llm-message"
type ToolCallResolution =
| { readonly type: "reject"; readonly error: SessionError.Error }
| { readonly type: "settle"; readonly settle: ToolRegistry.Materialization["settle"] }
interface Prepared {
readonly request: LLMRequest
readonly resolveToolCall: (name: string) => ToolCallResolution
/**
* One request-scoped execution operation. Unknown, hook-removed, and
* step-limit-violating calls fail individually through the same seam.
*/
readonly executeTool: ToolRegistry.ToolSet["execute"]
/** True when this request is the final Step; violating calls are rejected and no continuation follows. */
readonly stepLimitReached: boolean
}
interface PrepareInput {
@@ -94,14 +96,16 @@ export const layer = Layer.effect(
const model = resolved.model
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
const stepLimitReached = agent.info.steps !== undefined && input.step >= agent.info.steps
const executableTools = stepLimitReached ? undefined : yield* registry.materialize(agent.info.permissions)
// The final Step keeps definitions available to protocols with native "none",
// preserving their prompt cache prefix. Calls are still rejected at execution.
const toolSet = yield* registry.snapshot(agent.info.permissions)
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const system = [agent.info.system ? agent.info.system : PROMPT_DEFAULT, input.context.initial]
.filter((part) => part.length > 0)
.map(SystemPart.make)
const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey)
const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history
const toolDefinitions = executableTools?.definitions ?? []
const toolDefinitions = toolSet.definitions
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
// Hooks may reshape available definitions but cannot advertise tools omitted by permissions or the Step limit.
const contextEvent = yield* hooks.trigger("session", "context", {
@@ -131,22 +135,23 @@ export const layer = Layer.effect(
tools: hookedTools,
toolChoice: stepLimitReached ? "none" : undefined,
})
const resolveToolCall = (name: string): ToolCallResolution => {
if (!executableTools)
return {
type: "reject",
const executeTool: ToolRegistry.ToolSet["execute"] = (executeInput) => {
if (stepLimitReached)
return Effect.succeed({
status: "error",
error: { type: "tool.execution", message: "Tools are disabled after the maximum agent steps" },
}
if (toolsByName.has(name) && !Object.hasOwn(contextEvent.tools, name))
return {
type: "reject",
error: { type: "tool.execution", message: `Tool is not available for this request: ${name}` },
}
return { type: "settle", settle: executableTools.settle }
})
if (toolsByName.has(executeInput.call.name) && !Object.hasOwn(contextEvent.tools, executeInput.call.name))
return Effect.succeed({
status: "error",
error: { type: "tool.unknown", message: `Tool is not available for this request: ${executeInput.call.name}` },
})
return toolSet.execute(executeInput)
}
return {
request,
resolveToolCall,
executeTool,
stepLimitReached,
}
})
+6 -19
View File
@@ -144,21 +144,18 @@ const layer = Layer.effect(
}
yield* publish(event)
if (LLMEvent.is.toolInputError(event)) {
if (prepared.resolveToolCall(event.name).type === "settle") needsContinuation = true
if (!prepared.stepLimitReached) needsContinuation = true
return
}
if (event.type !== "tool-call" || event.providerExecuted) return
const tool = prepared.resolveToolCall(event.name)
if (tool.type === "reject") {
yield* serialized(publisher.failUnsettledTools(tool.error))
return
}
needsContinuation = true
// Unavailable calls fail individually through the same execution seam;
// continuation depends only on remaining Step allowance.
if (!prepared.stepLimitReached) needsContinuation = true
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
ownedToolFibers.push(
yield* Effect.uninterruptibleMask((restore) =>
restore(
tool.settle({
prepared.executeTool({
sessionID: session.id,
agent: agent.id,
messageID: assistantMessageID,
@@ -166,17 +163,7 @@ const layer = Layer.effect(
progress: (update) => serialized(publisher.progress(event.id, update)),
}),
).pipe(
Effect.flatMap((settlement) =>
publish(
LLMEvent.toolResult({
id: event.id,
name: event.name,
result: settlement.result,
output: settlement.output,
}),
settlement.error,
),
),
Effect.flatMap((execution) => serialized(publisher.toolExecution(event.id, event.name, execution))),
),
).pipe(FiberSet.run(toolFibers)),
)
@@ -1,5 +1,5 @@
import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
import { Effect } from "effect"
import { type LLMEvent, type ProviderMetadata, type ToolContent, type ToolResultValue } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
import { SessionEvent } from "../event"
@@ -11,6 +11,8 @@ import { AgentV2 } from "../../agent"
import { Snapshot } from "../../snapshot"
import { RelativePath } from "../../schema"
import { SessionUsage } from "../usage"
import { Tool } from "../../tool/tool"
import { MAX_BYTES } from "../../tool-output-store"
import type { ToolRegistry } from "../../tool/registry"
type Input = {
@@ -25,24 +27,11 @@ type Input = {
const record = (value: unknown): Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value }
const message = (value: unknown) => {
if (typeof value === "string") return value
try {
return JSON.stringify(value) ?? String(value)
} catch {
return String(value)
}
}
type SettledOutput =
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
| { readonly error: SessionError.Error }
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
if (result.type === "error") return { error: { type: "tool.execution", message: message(result.value) } }
const settled = value ?? ToolOutput.fromResultValue(result)
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
return { structured: record(settled.structured), content: settled.content }
/** Derives canonical model content from a provider-hosted tool result. */
const hostedContent = (result: ToolResultValue): readonly [ToolContent, ...ToolContent[]] => {
if (result.type === "content" && result.value.length > 0)
return result.value as unknown as readonly [ToolContent, ...ToolContent[]]
return [{ type: "text", text: Tool.stringify(result.value) }]
}
/** Persist one step without executing tools or starting a continuation step. */
@@ -60,11 +49,8 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
>()
const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) => {
if (!tool.progress) return {}
const first = tool.progress.content[0]
return {
...(first === undefined ? {} : { content: [first, ...tool.progress.content.slice(1)] as const }),
metadata: tool.progress.structured,
}
const metadata = Tool.jsonMetadata(tool.progress, MAX_BYTES)
return metadata === undefined ? {} : { metadata }
}
let assistantMessageID = input.assistantMessageID
let stepStarted = false
@@ -254,11 +240,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
const failTools = Effect.fnUntraced(function* (error: SessionError.Error, mode: "all" | "hosted" | "uncalled") {
let failed = false
for (const [callID, tool] of tools) {
if (
tool.settled ||
(mode === "hosted" && !tool.providerExecuted) ||
(mode === "uncalled" && tool.called)
)
if (tool.settled || (mode === "hosted" && !tool.providerExecuted) || (mode === "uncalled" && tool.called))
continue
tool.settled = true
failed = true
@@ -409,26 +391,27 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
return
}
case "tool-result": {
// Provider-hosted results only; local executions publish through `toolExecution`.
const tool = tools.get(event.id)
if (!tool?.called) return yield* Effect.die(new Error(`Tool result before call: ${event.id}`))
if (tool.name !== event.name)
return yield* Effect.die(new Error(`Tool result name changed for ${event.id}: ${tool.name} -> ${event.name}`))
if (tool.settled) {
// A late error result is a benign straggler (e.g. after an abort
// sweep); a late success would mean double execution, so it dies.
if (event.result.type === "error") return
return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`))
}
tool.settled = true
const result = error ? { error } : settledOutput(event.output, event.result)
const executed = event.providerExecuted === true || tool.providerExecuted
const resultState = providerState(event.providerMetadata)
if ("error" in result) {
if (error !== undefined || event.result.type === "error") {
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
error: result.error,
error: error ?? { type: "tool.execution", message: Tool.stringify(event.result.value) },
...failureSnapshot(tool),
result: event.result,
executed,
resultState,
})
@@ -438,8 +421,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
...result,
...(executed ? { result: event.result } : {}),
content: hostedContent(event.result),
executed,
resultState,
})
@@ -489,19 +471,64 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
const tool = tools.get(callID)
if (!tool?.called || tool.settled)
return yield* Effect.die(new Error(`Tool progress outside running call: ${callID}`))
const current = { structured: { ...update.structured }, content: [...update.content] }
const current = { ...update }
tool.progress = current
yield* events.publish(SessionEvent.Tool.Progress, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
...current,
metadata: current,
})
})
/** Publishes one canonical terminal event for a locally executed tool call. */
const toolExecution = Effect.fnUntraced(function* (
callID: string,
name: string,
execution: ToolRegistry.ToolOutcome,
) {
const tool = tools.get(callID)
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${callID}`))
if (tool.name !== name)
return yield* Effect.die(new Error(`Tool execution name changed for ${callID}: ${tool.name} -> ${name}`))
if (tool.settled) {
if (execution.status === "error") return
return yield* Effect.die(new Error(`Duplicate tool execution: ${callID}`))
}
tool.settled = true
if (execution.status === "completed") {
yield* events.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
content: execution.content,
...(execution.metadata === undefined ? {} : { metadata: execution.metadata }),
executed: tool.providerExecuted,
})
return
}
// An execution-provided snapshot wins; otherwise fall back to retained progress.
const snapshot =
execution.content !== undefined || execution.metadata !== undefined
? {
...(execution.content === undefined ? {} : { content: execution.content }),
...(execution.metadata === undefined ? {} : { metadata: execution.metadata }),
}
: failureSnapshot(tool)
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
error: execution.error,
...snapshot,
executed: tool.providerExecuted,
})
})
return {
publish,
progress,
toolExecution,
flush,
failAssistant,
publishStepFailure,
@@ -1,11 +1,4 @@
import {
Message,
ToolCallPart,
ToolOutput,
ToolResultPart,
type ContentPart,
type ProviderMetadata,
} from "@opencode-ai/ai"
import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai"
import { Option, Schema } from "effect"
import type { ModelV2 } from "../../model"
import { SessionMessage } from "../message"
@@ -90,15 +83,15 @@ const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: Provider
const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined) => {
if (tool.state.status === "completed") {
// TODO: Materialize remote and managed URIs before provider-history lowering.
// ToolOutput.toResultValue rejects unresolved URIs rather than treating them as media bytes.
const result =
tool.executed === true && tool.state.result !== undefined
? tool.state.result
: ToolOutput.toResultValue({ structured: tool.state.structured, content: tool.state.content })
const content = tool.state.content
const single = content.length === 1 ? content[0] : undefined
return ToolResultPart.make({
id: tool.id,
name: tool.name,
result,
result:
single?.type === "text"
? { type: "text" as const, value: single.text }
: { type: "content" as const, value: content },
providerExecuted: tool.executed,
providerMetadata,
})
@@ -107,10 +100,7 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
return ToolResultPart.make({
id: tool.id,
name: tool.name,
result:
tool.executed === true && tool.state.result !== undefined
? tool.state.result
: { error: tool.state.error, content: tool.state.content, structured: tool.state.structured },
result: { error: tool.state.error, content: tool.state.content ?? [] },
resultType: "error",
providerExecuted: tool.executed,
providerMetadata,
@@ -119,8 +109,8 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
}
const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, providerMetadataKey: string) => {
const sameModel =
String(message.model.providerID) === String(model.providerID) && String(message.model.id) === String(model.id)
const sameProvider = String(message.model.providerID) === String(model.providerID)
const sameModel = sameProvider && String(message.model.id) === String(model.id)
const reuseProviderMetadata = sameModel && message.error === undefined
const content = message.content.flatMap((item): ContentPart[] => {
if (item.type === "text") return [{ type: "text", text: item.text }]
@@ -138,19 +128,21 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
: []
const reuseToolProviderMetadata =
reuseProviderMetadata ||
(sameModel &&
item.executed === true &&
(item.state.status === "completed" || (item.state.status === "error" && item.state.result !== undefined)))
(sameModel && item.executed === true && (item.state.status === "completed" || item.state.status === "error"))
const call = toolCall(
item,
reuseToolProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined,
)
if (item.executed !== true) return [call]
// Hosted result payloads are provider-format state, not model state:
// replay must survive a model switch within the same provider.
const result = toolResult(
item,
reuseToolProviderMetadata
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
: undefined,
: sameProvider && item.executed === true && item.providerResultState !== undefined
? providerMetadata(providerMetadataKey, item.providerResultState)
: undefined,
)
return result ? [call, result] : [call]
})
@@ -39,8 +39,13 @@ export function toSessionError(cause: unknown): SessionError.Error {
}
if (cause instanceof PermissionV2.BlockedError) return { type: "permission.rejected", message: cause.message }
if (cause instanceof QuestionV2.RejectedError) return { type: "aborted", message: cause.message }
if (cause instanceof ToolFailure || cause instanceof Tool.Failure)
return cause.error === undefined ? { type: "tool.execution", message: cause.message } : toSessionError(cause.error)
if (cause instanceof ToolFailure || cause instanceof Tool.Failure) {
if (cause.error === undefined) return { type: "tool.execution", message: cause.message }
// The canonical error is the sole model-visible representation, so a cause
// with no message must not erase the tool's curated failure message.
const unwrapped = toSessionError(cause.error)
return unwrapped.message === "" ? { ...unwrapped, type: "tool.execution", message: cause.message } : unwrapped
}
if (cause instanceof StepFailedError) return cause.error
if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message }
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message }
+13 -23
View File
@@ -8,7 +8,7 @@ import { Global } from "@opencode-ai/util/global"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { SessionSchema } from "./session/schema"
import { Identifier } from "./util/identifier"
import type { ToolOutput } from "@opencode-ai/ai"
import type { ToolContent } from "@opencode-ai/ai"
export const MAX_LINES = 2_000
export const MAX_BYTES = 50 * 1024
@@ -19,11 +19,11 @@ export const MANAGED_DIRECTORY = "tool-output"
export interface BoundInput {
readonly sessionID: SessionSchema.ID
readonly callID: string
readonly output: ToolOutput
readonly content: ReadonlyArray<ToolContent>
}
export interface BoundResult {
readonly output: ToolOutput
readonly content: ReadonlyArray<ToolContent>
readonly outputPaths: ReadonlyArray<string>
}
@@ -137,21 +137,14 @@ const layer = Layer.effect(
const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) {
const outputLimits = yield* limits()
const media = input.output.content.filter((item) => item.type === "file")
const text = input.output.content.filter((item) => item.type === "text")
const contextual =
input.output.content.length === 0
? yield* Effect.try({
try: () => JSON.stringify(input.output.structured, null, 2) ?? String(input.output.structured),
catch: (cause) => new StorageError({ operation: "encode", cause }),
})
: text.map((item) => item.text).join("")
const media = input.content.filter((item) => item.type === "file")
const contextual = input.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("")
if (
lineCount(contextual) <= outputLimits.maxLines &&
Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes
)
return {
output: input.output,
content: input.content,
outputPaths: [],
}
@@ -159,16 +152,13 @@ const layer = Layer.effect(
const marker = `... output truncated; full content saved to ${outputPath} ...`
return {
output: {
structured: input.output.structured,
content: [
{
type: "text" as const,
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
},
...media,
],
},
content: [
{
type: "text" as const,
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
},
...media,
],
outputPaths: [outputPath],
}
})
+9 -9
View File
@@ -1,26 +1,26 @@
# Core Tool Architecture
This folder owns Core's one local tool representation, process and Location registration, effective lookup, and settlement.
This folder owns Core's local tools, Location-scoped registrations, effective lookup, execution, and terminal outcomes.
## Representations
- `tool.ts` defines the structural canonical `Tool.make({ description, input, output, execute, toModelOutput })` declaration. Shipped built-ins and plugin tools use the same type.
- `tool.ts` defines the structural canonical `Tool.make({ description, input, output?, execute })` tool. Executors return model content and metadata alongside declared machine output. Shipped built-ins and plugin tools use the same type.
- `tools.ts` exposes the registration-only `Tools.Service` view used by Location producers.
- `registry.ts` stores only canonical Location registrations, derives definitions, invokes tools, and applies generic output bounding.
- `registry.ts` stores only canonical Location registrations, derives LLM definitions, executes tools, and applies generic output bounding.
Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.
## Construction
Tool schemas and projection use `input` and `output` terminology. A tool value carries its schemas, executor, projection, and optional catalog permission directly so separately loaded plugin package instances can exchange it structurally.
Tool schemas use `input` and `output` terminology. A tool carries schemas and executable behavior without public identity. A registration binds its name, namespace, CodeMode placement, and optional catalog permission action.
Location-scoped built-in layers acquire `PermissionV2.Service` and every other required Location service while the layer is constructed. The executor captures those services. Permission sources are always constructed from the canonical invocation context:
```ts
const source = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
messageID: context.messageID,
callID: context.callID,
}
```
@@ -42,13 +42,13 @@ Registrations are scoped:
## Permissions
The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `patch` declare the shared `edit` action.
The registry has no `PermissionV2.Service` dependency and performs no execution authorization. Registration options may attach a permission action solely to preserve whole-tool definition filtering. Most registrations default to their effective name; `edit`, `write`, and `patch` use the shared `edit` action.
Definition filtering is catalog visibility, not execution authorization. A call still executes the captured leaf policy if it reaches settlement.
Tool filtering is catalog visibility, not execution authorization. A call still executes the captured tool's leaf policy if it reaches execution.
## Output
Built-ins return complete validated domain output. `ToolRegistry.Materialization.settle` is the only execution and generic model-output bounding boundary and owns managed retention paths.
Built-ins return complete tool responses. `ToolRegistry.ToolSet.execute` is the only local execution and generic model-output bounding boundary and owns managed retention paths.
Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss, but it does not run model-output truncation or return a managed `outputPath`.
+9 -9
View File
@@ -97,15 +97,11 @@ export const Plugin = {
.transform((draft) =>
draft.add(
name,
Tool.withPermission(
Tool.make({
Tool.make({
description:
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
input: Input,
output: Output,
toModelOutput: ({ input, output }) => [
{ type: "text", text: toModelOutput(output, input.oldString, input.newString) },
],
execute: (input, context) => {
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
@@ -207,12 +203,16 @@ export const Plugin = {
],
replacements,
} satisfies Output
})
}).pipe(
Effect.map((output) => ({
output,
content: toModelOutput(output, input.oldString, input.newString),
metadata: { files: output.files },
})),
)
},
}),
"edit",
),
{ codemode: false },
{ codemode: false, permission: "edit" },
),
)
.pipe(Effect.orDie)
+51 -54
View File
@@ -1,9 +1,10 @@
export * as ExecuteTool from "./execute"
export type { Registration } from "./tool"
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
import { ToolOutput } from "@opencode-ai/ai"
import type { ToolContent } from "@opencode-ai/ai"
import { Effect, Ref, Schema } from "effect"
import { definition, make, settle, type AnyTool } from "./tool"
import { execute, make, toLLMDefinition, type Content, type Metadata, type Registration } from "./tool"
const ExecuteFile = Schema.Struct({
data: Schema.String,
@@ -14,16 +15,11 @@ const ExecuteFile = Schema.Struct({
const ExecuteCall = Schema.Struct({
tool: Schema.String,
status: Schema.Literals(["running", "completed", "error"]),
input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)),
})
type ExecuteCall = typeof ExecuteCall.Type
const ExecuteMetadata = Schema.Struct({
toolCalls: Schema.Array(ExecuteCall),
error: Schema.optionalKey(Schema.Literal(true)),
})
const ExecuteOutput = Schema.Struct({
output: Schema.String,
toolCalls: Schema.Array(ExecuteCall),
@@ -36,12 +32,6 @@ type CollectedFiles = {
readonly files: Array<typeof ExecuteFile.Type>
}
export interface Registration {
readonly tool: AnyTool
readonly name: string
readonly namespace?: string
}
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
const description = [
"Run JavaScript in a confined Code Mode runtime through { code }.",
@@ -55,20 +45,6 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
description,
input: CodeMode.Input,
output: ExecuteOutput,
structured: ExecuteMetadata,
toStructuredOutput: ({ output }) => ({
toolCalls: output.toolCalls,
...(output.error ? { error: true as const } : {}),
}),
toModelOutput: ({ output }) => [
{ type: "text" as const, text: output.output },
...output.files.map((file) => ({
type: "file" as const,
data: file.data,
mime: file.mime,
...(file.name === undefined ? {} : { name: file.name }),
})),
],
execute: ({ code }, context) =>
Effect.gen(function* () {
const callIndex = yield* Ref.make(0)
@@ -85,21 +61,17 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
(name, registration, input) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
const output = yield* settle(
registration.tool,
{ type: "tool-call", id: context.callID, name, input },
{
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
callID: context.callID,
progress: context.progress,
},
).pipe(Effect.mapError((failure) => toolError(failure.message, failure)))
const outputFileParts = outputFiles(output)
const executed = yield* execute(registration.tool, input, {
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
callID: context.callID,
progress: context.progress,
}).pipe(Effect.mapError((failure) => toolError(failure.message, failure)))
const outputFileParts = outputFiles(executed.content)
if (outputFileParts.length > 0)
yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }])
return output.structured
return executed.output
}),
{
onToolCallStart: ({ index, name, input }) =>
@@ -126,39 +98,64 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
.toSorted((left, right) => left.index - right.index)
.flatMap((item) => item.files)
const output = formatResult(result)
return { output, toolCalls, files: collected, ...(result.ok ? {} : { error: true as const }) }
const value: typeof ExecuteOutput.Type = {
output,
toolCalls,
files: collected,
...(result.ok ? {} : { error: true }),
}
const content: [Content, ...Content[]] = [{ type: "text", text: value.output }]
content.push(
...value.files.map((file) => ({
type: "file" as const,
data: file.data,
mime: file.mime,
...(file.name === undefined ? {} : { name: file.name }),
})),
)
const metadata: Metadata = {
toolCalls: value.toolCalls,
...(value.error ? { error: true } : {}),
}
return {
output: value,
content,
metadata,
}
}),
})
}
export const instructions = (registrations: ReadonlyMap<string, Registration>) => {
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).instructions()
export const catalog = (registrations: ReadonlyMap<string, Registration>) => {
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).catalog()
}
function runtime(
registrations: ReadonlyMap<string, Registration>,
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
executeTool: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
) {
const tools: Record<string, Tool.Definition<never>> = {}
const tools: Record<string, Tool.Tool<never>> = {}
for (const [name, registration] of registrations) {
const child = definition(name, registration.tool)
const path = registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
const child = toLLMDefinition(name, registration.tool)
const path =
registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
tools[path] = Tool.make({
description: child.description,
input: child.inputSchema,
output: child.outputSchema,
run: (input) => invoke(name, registration, input),
execute: (input) => executeTool(name, registration, input),
})
}
return CodeMode.make<typeof tools>({ tools, ...hooks })
}
function displayInput(input: unknown): Record<string, unknown> | undefined {
// Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact.
function displayInput(input: unknown): Record<string, typeof Schema.Json.Type> | undefined {
if (input === null || input === undefined) return
if (typeof input !== "object" || Array.isArray(input)) return { input }
if (typeof input !== "object" || Array.isArray(input)) return { input: input as typeof Schema.Json.Type }
if (Object.keys(input).length === 0) return
return input as Record<string, unknown>
return input as Record<string, typeof Schema.Json.Type>
}
function formatResult(result: CodeMode.Result) {
@@ -180,8 +177,8 @@ function formatValue(value: CodeMode.DataValue) {
return JSON.stringify(value, null, 2) ?? String(value)
}
function outputFiles(output: ToolOutput): Array<typeof ExecuteFile.Type> {
return output.content.flatMap((part) => {
function outputFiles(content: ReadonlyArray<ToolContent>): Array<typeof ExecuteFile.Type> {
return content.flatMap((part) => {
if (part.type !== "file") return []
const prefix = `data:${part.mime};base64,`
if (!part.uri.startsWith(prefix)) return []
+8 -14
View File
@@ -8,7 +8,7 @@ import { FileSystem } from "../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location"
import { Ripgrep } from "../ripgrep"
import { NonNegativeInt, RelativePath } from "../schema"
import { RelativePath } from "../schema"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
@@ -25,9 +25,6 @@ export const Input = Schema.Struct({
})
export const Output = Schema.Array(FileSystem.Entry)
const StructuredOutput = Schema.Struct({
count: NonNegativeInt,
})
type ModelOutput = typeof Output.Encoded
/** Format raw search results into the concise line-oriented output models expect. */
@@ -54,16 +51,6 @@ export const Plugin = {
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ count: output.length }),
toModelOutput: ({ output }) => [
{
type: "text",
text: toModelOutput(
output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
),
},
],
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
@@ -104,6 +91,13 @@ export const Plugin = {
),
)
}).pipe(
Effect.map((output) => ({
output,
content: toModelOutput(
output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
),
metadata: { count: output.length },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
+11 -17
View File
@@ -9,7 +9,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location"
import { PermissionV2 } from "../permission"
import { Ripgrep } from "../ripgrep"
import { NonNegativeInt, RelativePath } from "../schema"
import { RelativePath } from "../schema"
import { Tool } from "./tool"
export const name = "grep"
@@ -30,9 +30,6 @@ export const Input = Schema.Struct({
})
export const Output = Schema.Array(FileSystem.Match)
const StructuredOutput = Schema.Struct({
matches: NonNegativeInt,
})
type ModelOutput = typeof Output.Encoded
/** Format raw search matches into the familiar concise model output. */
@@ -68,19 +65,6 @@ export const Plugin = {
"Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ matches: output.length }),
toModelOutput: ({ output }) => [
{
type: "text",
text: toModelOutput(
output.map((match) => ({
...match,
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
})),
),
},
],
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
@@ -135,6 +119,16 @@ export const Plugin = {
),
)
}).pipe(
Effect.map((output) => ({
output,
content: toModelOutput(
output.map((match) => ({
...match,
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
})),
),
metadata: { matches: output.length },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
+4 -23
View File
@@ -1,33 +1,14 @@
export * as ToolHooks from "./hooks"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "../session/message"
import { State } from "../state"
import { Context, Effect, Layer, Scope } from "effect"
import type { ToolOutput, ToolResultValue } from "@opencode-ai/ai"
import type { Tool } from "./tool"
export interface BeforeEvent {
readonly tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly callID: string
input: unknown
}
export type BeforeEvent = Tool.ToolExecuteBeforeEvent
export interface AfterEvent {
readonly tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly callID: string
readonly input: unknown
result: ToolResultValue
output?: ToolOutput
outputPaths?: ReadonlyArray<string>
}
/** The canonical execution outcome. Hooks never observe the raw domain output. */
export type AfterEvent = Tool.ToolExecuteAfterEvent
export interface Interface {
readonly hook: {
+75 -71
View File
@@ -32,86 +32,90 @@ export const layer = Layer.effectDiscard(
// registry never has a gap where MCP tools disappear mid-swap.
const reconcile = lock.withPermit(
Effect.gen(function* () {
const groups = new Map<string, { tools: Record<string, Tool.AnyTool>; codemode: boolean }>()
const groups = new Map<
string,
{
tools: Record<string, Tool.Any>
codemode: boolean
}
>()
for (const tool of yield* mcp.tools()) {
const group = groups.get(tool.server) ?? { tools: {}, codemode: tool.codemode !== false }
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
group.tools[tool.name] = Tool.withPermission(
Tool.make({
description: tool.description ?? "",
jsonSchema: {
...schema,
type: "object",
properties: schema.properties ?? {},
additionalProperties: false,
},
outputSchema: tool.outputSchema as JsonSchema.JsonSchema | undefined,
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
action: name(tool.server, tool.name),
resources: ["*"],
save: ["*"],
metadata: {},
sessionID: context.sessionID,
agent: context.agent,
source: {
type: "tool",
messageID: context.messageID,
callID: context.callID,
},
group.tools[tool.name] = Tool.make({
description: tool.description ?? "",
input: {
...schema,
type: "object",
properties: schema.properties ?? {},
additionalProperties: false,
},
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
action: name(tool.server, tool.name),
resources: ["*"],
save: ["*"],
metadata: {},
sessionID: context.sessionID,
agent: context.agent,
source: {
type: "tool",
messageID: context.messageID,
callID: context.callID,
},
})
const result = yield* mcp
.callTool({
server: tool.server,
name: tool.name,
args: (input ?? {}) as Record<string, unknown>,
})
const result = yield* mcp
.callTool({
server: tool.server,
name: tool.name,
args: (input ?? {}) as Record<string, unknown>,
})
.pipe(
Effect.catchTags({
"MCP.NotFoundError": (error) =>
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
}),
)
if (result.isError)
return yield* new ToolFailure({
message:
result.content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("\n")
.trim() || "MCP tool returned an error",
})
const content = result.content.map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: { type: "file" as const, data: part.data, mime: part.mimeType },
.pipe(
Effect.catchTags({
"MCP.NotFoundError": (error) =>
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
}),
)
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return {
structured: result.structured ?? (text === "" ? null : text),
content,
}
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
),
if (result.isError)
return yield* new ToolFailure({
message:
result.content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("\n")
.trim() || "MCP tool returned an error",
})
const content = result.content.map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: { type: "file" as const, data: part.data, mime: part.mimeType },
)
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return {
output: result.structured ?? (text === "" ? null : text),
...(content.length === 0 ? {} : { content: content as [Tool.Content, ...Tool.Content[]] }),
}
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
),
}),
name(tool.server, tool.name),
)
),
})
groups.set(tool.server, group)
}
const next = yield* Scope.fork(scope)
yield* Effect.forEach(
groups,
([server, group]) => tools.register(group.tools, { namespace: namespace(server), codemode: group.codemode }),
{
discard: true,
},
).pipe(Scope.provide(next), Effect.orDie)
yield* tools
.registerBatch(
Array.from(groups, ([server, group]) => ({
tools: group.tools,
options: { namespace: namespace(server), codemode: group.codemode },
})),
)
.pipe(Scope.provide(next), Effect.orDie)
if (current) yield* Scope.close(current, Exit.void)
current = next
}),
+10 -7
View File
@@ -75,12 +75,10 @@ export const Plugin = {
.transform((draft) =>
draft.add(
name,
Tool.withPermission(
Tool.make({
Tool.make({
description: DESCRIPTION,
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
const fail = (path: string, error?: unknown) => {
@@ -278,12 +276,17 @@ export const Plugin = {
{ discard: true },
)
return { applied, files: patchFiles }
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch", error))))
}).pipe(
Effect.map((output) => ({
output,
content: toModelOutput(output),
metadata: { files: output.files },
})),
Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch", error))),
)
},
}),
"edit",
),
{ codemode: false },
{ codemode: false, permission: "edit" },
),
)
.pipe(Effect.orDie)
+6 -4
View File
@@ -63,9 +63,6 @@ export const Plugin = {
description,
input: Input,
output: Output,
toModelOutput: ({ input, output }) => [
{ type: "text", text: toModelOutput(input.questions, output.answers) },
],
execute: (input, context) =>
permission
.assert({
@@ -95,13 +92,18 @@ export const Plugin = {
),
Effect.flatMap((state) => {
if (state.status === "cancelled") return Effect.die(new CancelledError())
return Effect.succeed({
const output = {
answers: input.questions.map((_, index): QuestionV2.Answer => {
const value = state.answer[`q${index}`]
if (value === undefined) return []
if (typeof value === "object") return Array.from(value)
return [String(value)]
}),
}
return Effect.succeed({
output,
content: toModelOutput(input.questions, output.answers),
metadata: { answers: output.answers },
})
}),
),
+14 -14
View File
@@ -48,20 +48,6 @@ export const Plugin = {
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.",
input: Input,
output: Output,
structured: Schema.toEncoded(Output),
// Image base64 reaches the model through content items (normalized generically
// at tool settlement); persisting a second copy in structured would store the
// original unresized bytes in the message row.
toStructuredOutput: ({ output }) =>
"encoding" in output && output.encoding === "base64" ? { ...output, content: "" } : output,
toModelOutput: ({ input, output }) => {
if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime))
return []
return [
{ type: "text", text: "Image read successfully" },
{ type: "file", data: output.content, mime: output.mime, name: input.path },
]
},
execute: (input, context) => {
return Effect.gen(function* () {
const source = {
@@ -125,6 +111,20 @@ export const Plugin = {
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
return content
}).pipe(
Effect.map((output) => {
// Image base64 reaches the model through content items; avoid a second
// unresized copy in model text.
const content =
"encoding" in output && output.encoding === "base64"
? SUPPORTED_IMAGE_MIMES.has(output.mime)
? ([
{ type: "text", text: "Image read successfully" },
{ type: "file", data: output.content, mime: output.mime, name: input.path },
] as const)
: JSON.stringify({ ...output, content: "" }, null, 2)
: JSON.stringify(output, null, 2)
return { output, content }
}),
Effect.mapError((error) => {
const message =
error instanceof ReadToolFileSystem.BinaryFileError ||
+158 -118
View File
@@ -1,7 +1,7 @@
export * as ToolRegistry from "./registry"
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/ai"
import { Context, Effect, Layer, Scope, Semaphore } from "effect"
import { type ToolCall, type ToolContent, type ToolDefinition } from "@opencode-ai/ai"
import { Context, Effect, Layer, Schema, Scope, Semaphore } from "effect"
import type { AgentV2 } from "../agent"
import { Image } from "../image"
import { PermissionV2 } from "../permission"
@@ -10,19 +10,10 @@ import { SessionSchema } from "../session/schema"
import { ToolOutputStore } from "../tool-output-store"
import { Wildcard } from "../util/wildcard"
import { CodeMode } from "../codemode"
import {
definition,
permission,
registrationEntries,
RegistrationError,
settle,
validateNamespace,
type AnyTool,
} from "./tool"
import { Tool, nonEmpty, registrationEntries, toLLMDefinition, validateName, validateNamespace } from "./tool"
import { Tools } from "./tools"
import { ToolHooks } from "./hooks"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { SessionError } from "@opencode-ai/schema/session-error"
import { toSessionError } from "../session/to-session-error"
export type ExecuteInput = {
@@ -33,38 +24,42 @@ export type ExecuteInput = {
readonly progress?: (update: Progress) => Effect.Effect<void>
}
export interface Progress {
readonly structured: Readonly<Record<string, unknown>>
readonly content: ToolOutput["content"]
}
/** Live replacement metadata for a running tool. */
export type Progress = Tool.Metadata
export interface Interface {
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
readonly snapshot: (permissions?: PermissionV2.Ruleset) => Effect.Effect<ToolSet>
/** Internal registration capability exposed publicly only through Tools.Service. */
readonly register: (
tools: Readonly<Record<string, AnyTool>>,
tools: Readonly<Record<string, Tool.Any>>,
options?: Tools.RegisterOptions,
) => Effect.Effect<void, RegistrationError, Scope.Scope>
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
/** Internal atomic registration capability used by plugin transforms. */
readonly registerBatch: (
registrations: ReadonlyArray<{
readonly tools: Readonly<Record<string, AnyTool>>
readonly tools: Readonly<Record<string, Tool.Any>>
readonly options?: Tools.RegisterOptions
}>,
) => Effect.Effect<void, RegistrationError, Scope.Scope>
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
}
export interface Materialization {
/**
* One request-scoped snapshot pairing advertised definitions with captured
* tools. A model request executes exactly the tool values it advertised
* even if registration changes while the request is in flight.
*/
export interface ToolSet {
readonly definitions: ReadonlyArray<ToolDefinition>
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement, ToolOutputStore.Error>
readonly execute: (input: ExecuteInput) => Effect.Effect<ToolOutcome, ToolOutputStore.Error>
}
export interface Settlement {
readonly result: ToolResultValue
readonly output?: ToolOutput
readonly outputPaths?: ReadonlyArray<string>
readonly error?: SessionError.Error
}
/**
* The canonical outcome of one local tool execution. `output` is the validated
* machine value for Code Mode and remains ephemeral; durable publication drops it.
*/
export type ToolOutcome =
| (Extract<Tool.Outcome, { readonly status: "completed" }> & { readonly output?: unknown })
| Extract<Tool.Outcome, { readonly status: "error" }>
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
@@ -76,26 +71,24 @@ const registryLayer = Layer.effect(
const image = yield* Image.Service
const codeMode = yield* CodeMode.Service
type NormalizedItem = ToolOutput["content"][number] | "decode" | "size"
const normalizeImages = Effect.fn("ToolRegistry.normalizeImages")(function* (content: ToolOutput["content"]) {
type NormalizedItem = ToolContent | "decode" | "size"
const normalizeImages = Effect.fn("ToolRegistry.normalizeImages")(function* (content: ReadonlyArray<ToolContent>) {
const normalized = yield* Effect.forEach(content, (item): Effect.Effect<NormalizedItem> => {
if (item.type !== "file" || !item.mime.startsWith("image/")) return Effect.succeed(item)
// RFC 2397 permits parameters between the mime and ";base64".
const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1]
if (base64 === undefined) return Effect.succeed(item)
const resource = item.name ?? `${item.mime} tool output`
return image
.normalize(resource, { uri: resource, content: base64, encoding: "base64", mime: item.mime })
.pipe(
Effect.map((result) => ({
...item,
uri: `data:${result.mime};base64,${result.content}`,
mime: result.mime,
})),
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(item)),
Effect.catchTag("Image.DecodeError", () => Effect.succeed("decode" as const)),
Effect.catchTag("Image.SizeError", () => Effect.succeed("size" as const)),
)
return image.normalize(resource, { uri: resource, content: base64, encoding: "base64", mime: item.mime }).pipe(
Effect.map((result) => ({
...item,
uri: `data:${result.mime};base64,${result.content}`,
mime: result.mime,
})),
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(item)),
Effect.catchTag("Image.DecodeError", () => Effect.succeed("decode" as const)),
Effect.catchTag("Image.SizeError", () => Effect.succeed("size" as const)),
)
})
const note = (reason: "decode" | "size", text: string) => {
const count = normalized.filter((item) => item === reason).length
@@ -108,16 +101,24 @@ const registryLayer = Layer.effect(
...note("size", "could not be resized below the image size limit."),
]
})
type Registration = {
readonly tool: AnyTool
readonly name: string
readonly namespace?: string
}
// Invalid or oversized metadata is dropped with a warning; it never fails a
// successful side-effecting tool.
const validMetadata = Effect.fnUntraced(function* (tool: string, metadata: Tool.Metadata | undefined) {
if (metadata === undefined) return undefined
const limits = yield* resources.limits()
const valid = Tool.jsonMetadata(metadata, limits.maxBytes)
if (valid === undefined)
yield* Effect.logWarning("dropping invalid or oversized tool metadata").pipe(Effect.annotateLogs({ tool }))
return valid
})
type Registration = Tool.Registration
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
const registrationLock = Semaphore.makeUnsafe(1)
const settleTool = Effect.fn("ToolRegistry.settleTool")(function* (input: ExecuteInput, tool: AnyTool) {
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleTool.
const executeTool = Effect.fn("ToolRegistry.executeTool")(function* (input: ExecuteInput, tool: Tool.Any) {
// Hooks fire only for hosted/local tools; provider-executed calls never reach executeTool.
const beforeEvent: ToolHooks.BeforeEvent = {
tool: input.call.name,
sessionID: input.sessionID,
@@ -127,76 +128,100 @@ const registryLayer = Layer.effect(
input: input.call.input,
}
yield* toolHooks.runBefore(beforeEvent)
const pending = yield* settle(
tool,
{ ...input.call, input: beforeEvent.input },
{
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
callID: input.call.id,
progress: (update) => {
const progress = input.progress
if (!progress) return Effect.void
return normalizeImages(
(update.content ?? []).map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: {
type: "file" as const,
uri: `data:${part.mime};base64,${part.data}`,
mime: part.mime,
name: part.name,
},
),
).pipe(Effect.flatMap((content) => progress({ structured: update.structured, content })))
},
const execution = yield* Tool.execute(tool, beforeEvent.input, {
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
callID: input.call.id,
progress: (metadata) => {
const progress = input.progress
if (!progress) return Effect.void
return validMetadata(input.call.name, metadata).pipe(
Effect.flatMap((valid) => (valid === undefined ? Effect.void : progress(valid))),
)
},
).pipe(
Effect.map((output) => ({ output })),
Effect.catchTag("LLM.ToolFailure", (failure) =>
Effect.succeed({
result: { type: "error" as const, value: failure.message },
error: toSessionError(failure),
}),
),
}).pipe(
Effect.map((value) => ({ value })),
Effect.catchTag("LLM.ToolFailure", (failure) => Effect.succeed({ failure: toSessionError(failure) })),
)
let settlement: Settlement
if ("result" in pending) {
settlement = pending
} else {
const outcome: ToolOutcome = yield* Effect.gen(function* () {
if ("failure" in execution) return { status: "error" as const, error: execution.failure }
const bounded = yield* resources.bound({
sessionID: input.sessionID,
callID: input.call.id,
output: { structured: pending.output.structured, content: yield* normalizeImages(pending.output.content) },
content: yield* normalizeImages(execution.value.content),
})
const result = ToolOutput.toResultValue(bounded.output)
settlement =
result.type === "error"
? bounded.outputPaths.length > 0
? { result, outputPaths: bounded.outputPaths }
: { result }
: bounded.outputPaths.length > 0
? { result, output: bounded.output, outputPaths: bounded.outputPaths }
: { result, output: bounded.output }
}
const afterEvent: ToolHooks.AfterEvent = {
const metadata = yield* validMetadata(input.call.name, execution.value.metadata)
return {
status: "completed" as const,
...(execution.value.output === undefined ? {} : { output: execution.value.output }),
content: nonEmpty(bounded.content) ?? execution.value.content,
...(metadata === undefined ? {} : { metadata }),
...(bounded.outputPaths.length > 0 ? { outputPaths: bounded.outputPaths } : {}),
}
})
const base = {
tool: input.call.name,
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
callID: input.call.id,
input: beforeEvent.input,
result: settlement.result,
output: settlement.output,
outputPaths: settlement.outputPaths,
}
const afterEvent: ToolHooks.AfterEvent =
outcome.status === "completed"
? {
...base,
status: "completed",
content: outcome.content,
...(outcome.metadata === undefined ? {} : { metadata: outcome.metadata }),
...(outcome.outputPaths === undefined ? {} : { outputPaths: outcome.outputPaths }),
}
: {
...base,
status: "error",
error: outcome.error,
...(outcome.content === undefined ? {} : { content: outcome.content }),
...(outcome.metadata === undefined ? {} : { metadata: outcome.metadata }),
...(outcome.outputPaths === undefined ? {} : { outputPaths: outcome.outputPaths }),
}
yield* toolHooks.runAfter(afterEvent)
const afterMetadata = yield* validMetadata(input.call.name, afterEvent.metadata)
const afterContent = yield* Effect.gen(function* () {
if (
afterEvent.content === undefined ||
(outcome.status === "completed" && afterEvent.content === outcome.content)
)
return { content: afterEvent.content, outputPaths: afterEvent.outputPaths }
const bounded = yield* resources.bound({
sessionID: input.sessionID,
callID: input.call.id,
content: yield* normalizeImages(afterEvent.content),
})
return {
content: nonEmpty(bounded.content),
outputPaths:
bounded.outputPaths.length === 0
? afterEvent.outputPaths
: Array.from(new Set([...(afterEvent.outputPaths ?? []), ...bounded.outputPaths])),
}
})
if (afterEvent.status === "completed")
return {
status: "completed" as const,
...(outcome.status === "completed" && outcome.output !== undefined ? { output: outcome.output } : {}),
content: afterContent.content ?? afterEvent.content,
...(afterMetadata === undefined ? {} : { metadata: afterMetadata }),
...(afterContent.outputPaths === undefined ? {} : { outputPaths: afterContent.outputPaths }),
}
return {
result: afterEvent.result,
...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}),
...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}),
...(settlement.error !== undefined ? { error: settlement.error } : {}),
status: "error" as const,
error: afterEvent.error,
...(afterContent.content === undefined ? {} : { content: afterContent.content }),
...(afterMetadata === undefined ? {} : { metadata: afterMetadata }),
...(afterContent.outputPaths === undefined ? {} : { outputPaths: afterContent.outputPaths }),
}
})
@@ -205,12 +230,26 @@ const registryLayer = Layer.effect(
const planned = yield* Effect.forEach(registrations, ({ tools, options }) =>
Effect.gen(function* () {
if (options?.namespace !== undefined) yield* validateNamespace(options.namespace)
const entries = registrationEntries(tools, options?.namespace)
const entries = registrationEntries(tools, options)
yield* Effect.forEach(entries, (entry) => validateName(entry.name), { discard: true })
const collision = entries.find(
(entry, index) => entries.findIndex((candidate) => candidate.key === entry.key) !== index,
)
if (collision)
return yield* Effect.fail(
new Tool.RegistrationError({
name: collision.key,
message: `Duplicate normalized tool name: ${collision.key}`,
}),
)
const codemode = options?.codemode ?? true
const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute")
if (reserved)
return yield* Effect.fail(
new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }),
new Tool.RegistrationError({
name: reserved.key,
message: 'Tool name "execute" is reserved for CodeMode',
}),
)
return { tools, options, entries, codemode }
}),
@@ -218,7 +257,7 @@ const registryLayer = Layer.effect(
// CodeMode registrations live in the CodeMode service; the registry keeps only direct tools.
yield* Effect.forEach(
planned.filter((plan) => plan.codemode && plan.entries.length > 0),
(plan) => codeMode.register(plan.tools, plan.options),
(plan) => codeMode.register(plan.entries),
{ discard: true },
)
const direct = planned.filter((plan) => !plan.codemode)
@@ -237,6 +276,7 @@ const registryLayer = Layer.effect(
tool: entry.tool,
name: entry.name,
namespace: entry.namespace,
permission: entry.permission,
},
},
])
@@ -269,7 +309,7 @@ const registryLayer = Layer.effect(
]),
),
registerBatch,
materialize: Effect.fn("ToolRegistry.materialize")((permissions) =>
snapshot: Effect.fn("ToolRegistry.snapshot")((permissions) =>
registrationLock.withPermit(
Effect.gen(function* () {
const direct = new Map<string, Registration>()
@@ -277,21 +317,21 @@ const registryLayer = Layer.effect(
for (const [name, entries] of local) {
const registration = entries.at(-1)?.registration
if (!registration) continue
if (whollyDisabled(permission(registration.tool, name), rules)) continue
if (whollyDisabled(registration.permission, rules)) continue
direct.set(name, registration)
}
const execute = (yield* codeMode.materialize(permissions)).tool
const codemodeTool = (yield* codeMode.materialize(permissions)).tool
return {
definitions: [
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
...(execute ? [definition("execute", execute)] : []),
...Array.from(direct, ([name, registration]) => toLLMDefinition(name, registration.tool)),
...(codemodeTool ? [toLLMDefinition("execute", codemodeTool)] : []),
],
settle: (input: ExecuteInput) => {
if (input.call.name === "execute" && execute) return settleTool(input, execute)
execute: (input: ExecuteInput) => {
if (input.call.name === "execute" && codemodeTool) return executeTool(input, codemodeTool)
const registration = direct.get(input.call.name)
if (registration) return settleTool(input, registration.tool)
return Effect.succeed({
result: { type: "error", value: `Unknown tool: ${input.call.name}` },
if (registration) return executeTool(input, registration.tool)
return Effect.succeed<ToolOutcome>({
status: "error",
error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` },
})
},
+20 -42
View File
@@ -3,7 +3,7 @@ export * as ShellTool from "./shell"
import path from "path"
import { ToolFailure } from "@opencode-ai/ai"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Fiber, Schedule, Schema, Scope } from "effect"
import { Deferred, Effect, Schema, Scope } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
@@ -147,19 +147,6 @@ export const Plugin = {
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. An optional timeout may be provided in milliseconds (zero: unlimited; foreground default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Background commands default to unlimited. Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({
truncated: output.truncated,
...(output.exit === undefined ? {} : { exit: output.exit }),
...(output.shellID === undefined ? {} : { shellID: output.shellID }),
...(output.timeout === undefined ? {} : { timeout: output.timeout }),
}),
toModelOutput: ({ output }) => {
const parts: Content[] = [{ type: "text", text: output.output }]
const model = modelOutput(output)
if (model) parts.push({ type: "text", text: model })
return parts
},
execute: (input, context) =>
Effect.gen(function* () {
const source = {
@@ -199,6 +186,7 @@ export const Plugin = {
timeout,
metadata: { sessionID: context.sessionID },
})
yield* context.progress({ shellID: info.id })
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
@@ -232,7 +220,9 @@ export const Plugin = {
}
})
const settled = yield* Deferred.make<Output>()
const run = settleShell().pipe(
Effect.tap((output) => Deferred.succeed(settled, output)),
Effect.map((output) => output.output),
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
@@ -256,32 +246,8 @@ export const Plugin = {
}
}
let previousProgress: { readonly output: string; readonly truncated: boolean } | undefined
const progress = yield* Effect.sleep("1 second").pipe(
Effect.andThen(
captureShell().pipe(
Effect.flatMap((capture) =>
Effect.gen(function* () {
if (
previousProgress?.output === capture.output &&
previousProgress.truncated === capture.truncated
)
return
previousProgress = capture
yield* context.progress({
structured: { truncated: capture.truncated },
content: [{ type: "text", text: capture.output }],
})
}),
),
),
),
Effect.repeat(Schedule.forever),
Effect.forkIn(scope, { startImmediately: true }),
)
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe(
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)),
Effect.ensuring(Fiber.interrupt(progress)),
)
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
@@ -298,11 +264,23 @@ export const Plugin = {
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
return {
...(yield* settleShell()),
...(warnings.length ? { warnings } : {}),
}
return { ...(yield* Deferred.await(settled)), ...(warnings.length ? { warnings } : {}) }
}).pipe(
Effect.map((output) => {
const content: [Content, ...Content[]] = [{ type: "text", text: output.output }]
const model = modelOutput(output)
if (model) content.push({ type: "text", text: model })
return {
output,
content,
metadata: {
truncated: output.truncated,
...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}),
...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}),
...("timeout" in output && output.timeout !== undefined ? { timeout: output.timeout } : {}),
},
}
}),
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
),
+7 -9
View File
@@ -21,11 +21,6 @@ export const Output = Schema.Struct({
directory: Schema.String,
output: Schema.String,
})
const StructuredOutput = Schema.Struct({
name: Output.fields.name,
directory: Output.fields.directory,
})
export const description = [
"Load a specialized skill when the task at hand matches one of the available skills in the instructions.",
"",
@@ -70,9 +65,6 @@ export const Plugin = {
description,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ name: output.name, directory: output.directory }),
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
const current = yield* skills.list()
@@ -101,7 +93,13 @@ export const Plugin = {
output: toModelOutput(skill, files),
}
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
}),
}).pipe(
Effect.map((output) => ({
output,
content: output.output,
metadata: { name: output.name, directory: output.directory },
})),
),
}),
{ codemode: false },
),
+8 -10
View File
@@ -31,11 +31,6 @@ export const Output = Schema.Struct({
status: Schema.Literals(["completed", "running"]),
output: Schema.String,
})
const StructuredOutput = Schema.Struct({
sessionID: Output.fields.sessionID,
status: Output.fields.status,
})
export const description = [
"Spawn a subagent: a child session running a configured agent with fresh context.",
"Foreground (default) runs the subagent to completion and returns its final response.",
@@ -119,9 +114,6 @@ export const Plugin = {
description,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ sessionID: output.sessionID, status: output.status }),
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
const parent = yield* runtime.session
@@ -186,7 +178,7 @@ export const Plugin = {
const background = input.background === true
yield* context.progress({
structured: { sessionID: child.id, status: "running" },
metadata: { sessionID: child.id, status: "running" },
})
const run = Effect.gen(function* () {
@@ -238,7 +230,13 @@ export const Plugin = {
if (result?.info.status === "cancelled")
return yield* new ToolFailure({ message: "Subagent cancelled" })
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
}),
}).pipe(
Effect.map((output) => ({
output,
content: output.output,
metadata: { sessionID: output.sessionID, status: output.status },
})),
),
}),
{ codemode: false },
),
+89 -1
View File
@@ -1,2 +1,90 @@
export * as Tool from "@opencode-ai/plugin/v2/effect/tool"
export * as Tool from "./tool"
export * from "@opencode-ai/plugin/v2/effect/tool"
import type { ToolContent } from "@opencode-ai/ai"
import {
decodeInput,
encodeOutput,
type Any,
type Content,
type Context,
Failure,
type Metadata,
} from "@opencode-ai/plugin/v2/effect/tool"
import { Effect, Schema } from "effect"
/** Non-empty canonical model content. */
export type NonEmptyContent = readonly [ToolContent, ...ToolContent[]]
/**
* The execution-local result of one tool call: the machine output for
* Code Mode, canonical model content, and optional UI metadata. The typed
* domain output never leaves this function.
*/
export type Execution = {
readonly output?: unknown
readonly content: NonEmptyContent
readonly metadata?: Metadata
}
export const execute = (tool: Any, input: unknown, context: Context): Effect.Effect<Execution, Failure> =>
Effect.gen(function* () {
const decoded = yield* decodeInput(tool.input, input)
const result = yield* tool.execute(decoded, context)
if (tool.output === undefined) {
if ("output" in result) return yield* Effect.die("Tool result declared output without an output schema")
return {
content: contentFrom(result.content),
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
}
}
if (!("output" in result))
return yield* Effect.fail(new Failure({ message: "Tool did not return its declared output" }))
const encoded = yield* encodeOutput(tool.output, result.output)
return {
output: encoded,
content: contentFrom(result.content, encoded),
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
}
})
/** Model content from the tool's projection, falling back to the stringified encoded output. */
const contentFrom = (projected: string | ReadonlyArray<Content> | undefined, encoded?: unknown): NonEmptyContent => {
if (typeof projected === "string") return [textContent(projected)]
if (projected !== undefined) {
const mapped = nonEmpty(projected.map(toModelContent))
if (mapped !== undefined) return mapped
}
return [textContent(stringify(encoded))]
}
export const toModelContent = (part: Content): ToolContent =>
part.type === "text"
? { type: "text", text: part.text }
: { type: "file", uri: `data:${part.mime};base64,${part.data}`, mime: part.mime, name: part.name }
export const nonEmpty = (content: ReadonlyArray<ToolContent>): NonEmptyContent | undefined =>
content.length > 0 ? (content as NonEmptyContent) : undefined
const textContent = (text: string): ToolContent => ({ type: "text", text })
/** Human-readable text for an arbitrary value; strings pass through unchanged. */
export const stringify = (value: unknown) => {
if (typeof value === "string") return value
try {
return JSON.stringify(value) ?? String(value)
} catch {
return String(value)
}
}
const MetadataSchema = Schema.Record(Schema.String, Schema.Json)
/** Defensive boundary: non-JSON or oversized metadata is dropped, never failing the producing call. */
export const jsonMetadata = (value: unknown, maxBytes?: number): Metadata | undefined => {
if (value === undefined) return undefined
const decoded = Schema.decodeUnknownOption(MetadataSchema)(value)
if (decoded._tag === "None") return undefined
if (maxBytes !== undefined && Buffer.byteLength(JSON.stringify(decoded.value), "utf-8") > maxBytes) return undefined
return decoded.value
}
+2 -2
View File
@@ -7,13 +7,13 @@ export type RegisterOptions = Tool.RegisterOptions
export interface Interface {
readonly register: (
tools: Readonly<Record<string, Tool.AnyTool>>,
tools: Readonly<Record<string, Tool.Any>>,
options?: Tool.RegisterOptions,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
/** Internal atomic registration capability used by plugin transforms. */
readonly registerBatch: (
registrations: ReadonlyArray<{
readonly tools: Readonly<Record<string, Tool.AnyTool>>
readonly tools: Readonly<Record<string, Tool.Any>>
readonly options?: Tool.RegisterOptions
}>,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
+2 -8
View File
@@ -37,10 +37,6 @@ const Output = Schema.Struct({
format: Input.fields.format,
output: Schema.String,
})
const StructuredOutput = Schema.Struct({
contentType: Output.fields.contentType,
})
type Format = (typeof Input.Type)["format"]
const acceptHeader = (format: Format) => {
@@ -129,9 +125,6 @@ export const Plugin = {
description,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ contentType: output.contentType }),
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
yield* Effect.try({
@@ -171,12 +164,13 @@ export const Plugin = {
try: () => convert(content, contentType, input.format),
catch: (error) => error,
})
return {
const result = {
url: input.url,
contentType,
format: input.format,
output,
}
return { output: result, content: result.output, metadata: { contentType: result.contentType } }
}).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))),
}),
{ codemode: false },
+2 -8
View File
@@ -190,10 +190,6 @@ const Output = Schema.Struct({
provider: Provider,
text: Schema.String,
})
const StructuredOutput = Schema.Struct({
provider: Output.fields.provider,
})
export const Plugin = {
id: "opencode.tool.websearch",
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
@@ -209,9 +205,6 @@ export const Plugin = {
description,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ provider: output.provider }),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
execute: (input, context) => {
const provider = selectProvider(context.sessionID, config, config.provider)
return Effect.gen(function* () {
@@ -250,10 +243,11 @@ export const Plugin = {
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
},
)
return {
const output = {
provider,
text: text ?? NO_RESULTS,
}
return { output, content: output.text, metadata: { provider: output.provider } }
}).pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
+3 -6
View File
@@ -53,13 +53,11 @@ export const Plugin = {
.transform((draft) =>
draft.add(
name,
Tool.withPermission(
Tool.make({
Tool.make({
description:
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) =>
Effect.gen(function* () {
const source = {
@@ -86,12 +84,11 @@ export const Plugin = {
})
return yield* files.writeTextPreservingBom({ target, content: input.content })
}).pipe(
Effect.map((output) => ({ output, content: toModelOutput(output) })),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
),
}),
"edit",
),
{ codemode: false },
{ codemode: false, permission: "edit" },
),
)
.pipe(Effect.orDie)
+16 -9
View File
@@ -9,19 +9,26 @@ describe("CodeMode", () => {
it.effect("owns registrations, execute, and catalog materialization", () =>
Effect.gen(function* () {
const codeMode = yield* CodeMode.Service
yield* codeMode.register({
echo: Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.String,
execute: ({ text }) => Effect.succeed(text),
yield* codeMode.register(
Tool.registrationEntries({
echo: Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.String,
execute: ({ text }) => Effect.succeed({ output: text }),
}),
}),
})
)
const materialized = yield* codeMode.materialize()
expect(materialized.tool).toBeDefined()
expect(materialized.instructions).toContain("Echo text")
expect(materialized.instructions).toContain("tools.echo(input:")
expect(materialized.catalog).toStrictEqual([
{
path: "echo",
description: "Echo text",
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
},
])
}).pipe(Effect.scoped, Effect.provide(AppNodeBuilder.build(CodeMode.node))),
)
})
+183
View File
@@ -0,0 +1,183 @@
import { describe, expect, test } from "bun:test"
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
const entry = (path: string, description: string, signature?: string): CodeModeCatalog.Entry => ({
path,
description,
signature: signature ?? `tools.${path}(input: {\n q: string,\n}): Promise<string>`,
})
const lookup = entry(
"orders.lookup",
"Look up an order by ID",
"tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>",
)
const render = (entries: ReadonlyArray<CodeModeCatalog.Entry>, budget?: number) =>
CodeModeInstructions.render(CodeModeCatalog.summarize(entries, budget))
const update = (
previous: ReadonlyArray<CodeModeCatalog.Entry>,
current: ReadonlyArray<CodeModeCatalog.Entry>,
budget?: number,
) =>
CodeModeInstructions.update(CodeModeCatalog.summarize(previous, budget), CodeModeCatalog.summarize(current, budget))
describe("CodeModeCatalog.summarize", () => {
test("retains namespace inventory without retaining tools outside the inline budget", () => {
const catalog = CodeModeCatalog.summarize(
Array.from({ length: 10_000 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`)),
0,
)
expect(catalog).toEqual({
total: 10_000,
shown: 0,
namespaces: [{ name: "bulk", count: 10_000, entries: [] }],
})
})
test("retains every namespace when no full tool listing fits", () => {
const catalog = CodeModeCatalog.summarize(
[entry("alpha.one", "One"), entry("beta.two", "Two"), entry("gamma.three", "Three")],
0,
)
expect(catalog.namespaces.map((namespace) => namespace.name)).toEqual(["alpha", "beta", "gamma"])
expect(catalog.namespaces.every((namespace) => namespace.entries.length === 0)).toBe(true)
})
test("retains only the rendered portion of inline descriptions", () => {
const catalog = CodeModeCatalog.summarize([entry("alpha.one", `Summary\n${"detail".repeat(10_000)}`)])
expect(catalog.namespaces[0]?.entries[0]?.line).toEndWith("// Summary")
})
test("limits inline descriptions to 120 characters", () => {
const catalog = CodeModeCatalog.summarize([entry("alpha.one", "x".repeat(121))])
const description = catalog.namespaces[0]?.entries[0]?.line.split(" // ")[1]
expect(description).toHaveLength(120)
expect(description).toEndWith("...")
})
})
describe("CodeModeInstructions.render", () => {
test("inlines complete catalogs without search guidance", () => {
const instructions = render([lookup])
expect(instructions).toContain("## Available tools")
expect(instructions).toContain("- orders (1 tool)")
expect(instructions).toContain(` - ${lookup.signature} // Look up an order by ID`)
expect(instructions).not.toContain("## Search")
expect(instructions).toContain("Do not infer or normalize tool names")
expect(instructions).toContain('`tools.<namespace>["tool-name"](input)`')
})
test("describes the runtime and execution lifecycle concisely", () => {
const instructions = render([lookup])
expect(instructions).toContain("Run JavaScript to orchestrate tool calls and compose their results.")
expect(instructions).toContain("Imports, filesystem access, and timers are unavailable.")
expect(instructions).toContain("Do not use `fetch`; all API calls go through `tools`.")
expect(instructions).toContain(
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
)
expect(instructions).toContain("any calls still pending when execution ends are interrupted")
expect(instructions).toContain("Run independent calls concurrently with `Promise.all`.")
})
test("adds search guidance when the catalog exceeds the budget", () => {
const partial = render([lookup], 0)
expect(partial).toContain("## Available tools")
expect(partial).toContain("- orders (1 tool, none shown)")
expect(partial).toContain("## Search")
expect(partial).toContain("Only some tool signatures are shown.")
expect(partial).toContain("- search(input: {")
expect(partial).toContain(" limit?: number,\n offset?: number,")
expect(partial).toContain("or returned by `search`")
expect(partial).not.toContain("tools.orders.lookup(input:")
})
test("budgets signatures round-robin so every namespace remains visible", () => {
const cheapAlpha = entry("alpha.cheap", "Cheap")
const cheapBeta = entry("beta.cheap", "Cheap")
const expensive = entry(
"alpha.expensive",
"Expensive",
`tools.alpha.expensive(input: {\n aVeryLongParameterName: string,\n anotherEvenLongerParameterName: number,\n yetAnotherExtremelyVerboseParameterName: string,\n}): Promise<string>`,
)
// Round 1 places alpha.cheap and beta.cheap; in round 2 alpha.expensive does not fit,
// which marks only alpha done - it must NOT prevent other namespaces from inlining.
const instructions = render([cheapAlpha, expensive, cheapBeta], 40)
expect(instructions).toContain("## Search")
expect(instructions).toContain("- alpha (2 tools, 1 shown)")
expect(instructions).toContain(` - ${cheapAlpha.signature} // Cheap`)
expect(instructions).not.toContain("tools.alpha.expensive(")
expect(instructions).toContain("- beta (1 tool)")
expect(instructions).toContain(` - ${cheapBeta.signature} // Cheap`)
})
test("charges inline JSDoc in signatures against the catalog token budget", () => {
const documented = entry(
"records.lookup",
"Look up a record",
`tools.records.lookup(input: {\n /** ${"A detailed identifier description. ".repeat(20).trim()} */\n id: string,\n}): Promise<string>`,
)
const instructions = render([documented], 40)
expect(instructions).toContain("- records (1 tool, none shown)")
expect(instructions).not.toContain("tools.records.lookup(input:")
})
test("renders only the no-tools notice for an empty catalog", () => {
expect(render([])).toBe("No tools are currently available.")
})
})
describe("CodeModeInstructions.update", () => {
const echo = entry("notes.echo", "Echo text")
test("renders additions, changes, and removals as a compact semantic delta", () => {
const changed = { ...echo, signature: "tools.notes.echo(input: {\n text: string,\n}): Promise<string>" }
const added = entry("notes.list", "List notes")
const text = update([echo, lookup], [changed, added])
expect(text).toContain("The Code Mode tool catalog has changed.")
expect(text).toContain(`New tools are available in addition to those previously listed:\n - ${added.signature}`)
expect(text).toContain(
`Changed tool listings supersede the previously listed ones:\n - ${changed.signature} // Echo text`,
)
expect(text).toContain("The following tools are no longer available and must not be called: tools.orders.lookup.")
expect(text).not.toContain("## Available tools")
})
test("names removed tools with exact callable expressions including bracket notation", () => {
const dashed = entry("context7.resolve-library-id", "Resolve a library ID")
const text = update([echo, dashed], [echo])
expect(text).toContain(
'The following tools are no longer available and must not be called: tools.context7["resolve-library-id"].',
)
})
test("restates the full catalog when the rendering mode crosses full and compact", () => {
const wide = Array.from({ length: 40 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`))
const text = update([echo], [echo, ...wide], 30)
expect(text).toContain(
"The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.",
)
expect(text).toContain("## Search")
expect(text).toContain("## Available tools")
})
test("falls back to full replacement when the delta is larger than the catalog", () => {
const previous = Array.from({ length: 200 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`))
const text = update([...previous, echo], [echo])
expect(text).toContain("This catalog supersedes the previous Code Mode tool catalog.")
expect(text).toContain("## Available tools")
expect(text).not.toContain("## Search")
expect(text).not.toContain("must not be called")
})
test("renders namespace-only deltas without persisting hidden tool entries", () => {
const alpha = Array.from({ length: 10 }, (_, index) => entry(`alpha.tool${index}`, `Tool ${index}`))
const text = update(alpha, [...alpha, entry("alpha.tool10", "Tool 10")], 0)
expect(text).toContain("`alpha` now has 11 tools")
expect(text).toContain("search them again before relying on previous results")
expect(text).not.toContain("tools.alpha.tool10(input:")
expect(text).not.toContain("## Available tools")
})
})
@@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { AgentV2 } from "@opencode-ai/core/agent"
import { CodeMode } from "@opencode-ai/core/codemode"
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Effect, Layer } from "effect"
@@ -9,14 +10,26 @@ import { readInitial, readUpdate } from "../lib/instructions"
const agent = AgentV2.Info.make(AgentV2.Info.empty(AgentV2.ID.make("build")))
const echo = {
path: "notes.echo",
description: "Echo text",
signature: "tools.notes.echo(input: {\n text: string,\n}): Promise<string>",
}
const lookup = {
path: "orders.lookup",
description: "Look up an order",
signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise<unknown>",
}
describe("CodeModeInstructions", () => {
it.effect("renders catalog changes and removal", () => {
let catalog: string | undefined = "Initial Code Mode catalog"
it.effect("renders the initial catalog, semantic deltas, and removal", () => {
let catalog: ReadonlyArray<CodeModeCatalog.Entry> | undefined = [echo]
const layer = AppNodeBuilder.build(CodeModeInstructions.node, [
[
CodeMode.node,
Layer.mock(CodeMode.Service, {
materialize: () => Effect.succeed({ ...(catalog === undefined ? {} : { instructions: catalog }) }),
materialize: () => Effect.succeed({ ...(catalog === undefined ? {} : { catalog }) }),
register: () => Effect.void,
}),
],
@@ -25,16 +38,27 @@ describe("CodeModeInstructions", () => {
return Effect.gen(function* () {
const instructions = yield* CodeModeInstructions.Service
const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
expect(initialized.text).toBe("Initial Code Mode catalog")
expect(initialized.text).toContain("## Available tools")
expect(initialized.text).not.toContain("## Search")
expect(initialized.text).toContain(` - ${echo.signature} // Echo text`)
catalog = "Updated Code Mode catalog"
expect(
yield* instructions
.load({ id: agent.id, info: agent })
.pipe(Effect.flatMap((context) => readUpdate(context, initialized))),
).toMatchObject({
text: "The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.\n\nUpdated Code Mode catalog",
})
catalog = [echo, lookup]
const added = yield* instructions
.load({ id: agent.id, info: agent })
.pipe(Effect.flatMap((context) => readUpdate(context, initialized)))
expect(added.text).toContain("The Code Mode tool catalog has changed.")
expect(added.text).toContain("New tools are available in addition to those previously listed:")
expect(added.text).toContain(` - ${lookup.signature} // Look up an order`)
expect(added.text).not.toContain("## Available tools")
catalog = [echo]
const removed = yield* instructions
.load({ id: agent.id, info: agent })
.pipe(Effect.flatMap((context) => readUpdate(context, { values: added.values })))
expect(removed.text).toBe(
"The Code Mode tool catalog has changed.\n\n" +
"The following tools are no longer available and must not be called: tools.orders.lookup.",
)
catalog = undefined
expect(
@@ -46,4 +70,28 @@ describe("CodeModeInstructions", () => {
})
}).pipe(Effect.provide(layer))
})
it.effect("stores a canonical sorted snapshot so registration order does not churn history", () => {
let catalog: ReadonlyArray<CodeModeCatalog.Entry> = [lookup, echo]
const layer = AppNodeBuilder.build(CodeModeInstructions.node, [
[
CodeMode.node,
Layer.mock(CodeMode.Service, {
materialize: () => Effect.succeed({ catalog }),
register: () => Effect.void,
}),
],
])
return Effect.gen(function* () {
const instructions = yield* CodeModeInstructions.Service
const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
catalog = [echo, lookup]
const update = yield* instructions
.load({ id: agent.id, info: agent })
.pipe(Effect.flatMap((context) => readUpdate(context, initialized)))
expect(update.changed).toBe(false)
}).pipe(Effect.provide(layer))
})
})
@@ -25,6 +25,7 @@ import addSessionForkMigration from "@opencode-ai/core/database/migration/202607
import timeSuspendedMigration from "@opencode-ai/core/database/migration/20260709163752_time_suspended"
import instructionSyncMigration from "@opencode-ai/core/database/migration/20260710025429_instruction_sync"
import deleteToolProgressEventsMigration from "@opencode-ai/core/database/migration/20260722011141_delete_tool_progress_events"
import canonicalToolResultsMigration from "@opencode-ai/core/database/migration/20260722170000_canonical_tool_results"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
@@ -583,6 +584,208 @@ describe("DatabaseMigration", () => {
)
})
test("rewrites projected tool rows into the canonical result shape", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(
sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`,
)
yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, type text NOT NULL, data text NOT NULL)`)
const assistant = {
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{ type: "text", text: "before" },
{
type: "tool",
id: "call_content",
name: "grep",
state: {
status: "completed",
input: { pattern: "TODO" },
content: [{ type: "text", text: "src/a.ts:1: TODO" }],
structured: { value: [{ file: "src/a.ts", line: 1 }] },
},
time: { created: 1, completed: 2 },
},
{
type: "tool",
id: "call_structured_only",
name: "read",
state: {
status: "completed",
input: { path: "README.md" },
content: [],
structured: { text: "hello" },
},
time: { created: 1, completed: 2 },
},
{
type: "tool",
id: "call_hosted",
name: "web_search",
executed: true,
providerResultState: { blockType: "web_search_tool_result" },
state: {
status: "completed",
input: { query: "effect" },
content: [],
structured: {},
result: { type: "json", value: [{ url: "https://example.com" }] },
},
time: { created: 1, completed: 2 },
},
{
type: "tool",
id: "call_failed",
name: "shell",
state: {
status: "error",
input: { command: "sleep 99" },
error: { type: "tool.execution", message: "timed out" },
content: [{ type: "text", text: "partial output" }],
structured: { truncated: false },
result: { type: "error", value: "timed out" },
},
time: { created: 1, completed: 2 },
},
{
type: "tool",
id: "call_running",
name: "shell",
state: {
status: "running",
input: { command: "sleep 1" },
structured: { truncated: false },
content: [{ type: "text", text: "tick" }],
},
time: { created: 1, ran: 2 },
},
],
time: { created: 1 },
}
yield* db.run(
sql`INSERT INTO session_message VALUES ('msg_tools', 'ses_test', 'assistant', 1, 10, 11, ${JSON.stringify(assistant)})`,
)
yield* db.run(
sql`INSERT INTO session_message VALUES ('msg_user', 'ses_test', 'user', 2, 12, 13, '{"text":"hi","time":{"created":1}}')`,
)
// A row that never decoded must be skipped, not fail the migration.
yield* db.run(
sql`INSERT INTO session_message VALUES ('msg_corrupt', 'ses_test', 'assistant', 3, 14, 15, 'not json')`,
)
yield* db.run(
sql`INSERT INTO event VALUES ('evt_success', 'session.tool.success.1', ${JSON.stringify({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
callID: "call_hosted",
structured: {},
content: [],
result: { type: "json", value: [{ url: "https://example.com" }] },
executed: true,
})})`,
)
yield* db.run(
sql`INSERT INTO event VALUES ('evt_failed', 'session.tool.failed.1', ${JSON.stringify({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
callID: "call_failed",
error: { type: "tool.execution", message: "timed out" },
metadata: { truncated: false },
executed: false,
})})`,
)
yield* DatabaseMigration.applyOnly(db, [canonicalToolResultsMigration])
const row = yield* db.get<{ data: string }>(sql`SELECT data FROM session_message WHERE id = 'msg_tools'`)
const migrated = JSON.parse(row!.data)
// Every migrated row must decode with the current schema; reload hard-fails otherwise.
Schema.decodeUnknownSync(SessionMessage.Info)({ ...migrated, id: "msg_tools", type: "assistant" })
const states = new Map(
migrated.content.flatMap((part: { type: string; id?: string }) =>
part.type === "tool" ? [[part.id, part]] : [],
),
)
expect(states.get("call_content")).toMatchObject({
state: {
status: "completed",
input: { pattern: "TODO" },
content: [{ type: "text", text: "src/a.ts:1: TODO" }],
// Old generic structured payloads survive as canonical metadata.
metadata: { value: [{ file: "src/a.ts", line: 1 }] },
},
})
expect((states.get("call_content") as { state: Record<string, unknown> }).state).not.toHaveProperty(
"structured",
)
expect(states.get("call_structured_only")).toMatchObject({
state: {
status: "completed",
content: [{ type: "text", text: JSON.stringify({ text: "hello" }, null, 2) }],
metadata: { text: "hello" },
},
})
expect(states.get("call_hosted")).toMatchObject({
executed: true,
providerResultState: {
blockType: "web_search_tool_result",
result: [{ url: "https://example.com" }],
},
state: {
status: "completed",
content: [{ type: "text", text: JSON.stringify([{ url: "https://example.com" }], null, 2) }],
},
})
expect(states.get("call_failed")).toMatchObject({
state: {
status: "error",
error: { type: "tool.execution", message: "timed out" },
content: [{ type: "text", text: "partial output" }],
metadata: { truncated: false },
},
})
const failedState = (states.get("call_failed") as { state: Record<string, unknown> }).state
expect(failedState).not.toHaveProperty("result")
expect(failedState).not.toHaveProperty("structured")
expect(states.get("call_running")).toMatchObject({
state: {
status: "running",
metadata: { truncated: false },
},
})
const event = yield* db.get<{ type: string; data: string }>(sql`SELECT type, data FROM event WHERE id = 'evt_success'`)
expect(event!.type).toBe("session.tool.success.1")
expect(JSON.parse(event!.data)).toEqual({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
callID: "call_hosted",
structured: {},
content: [],
result: { type: "json", value: [{ url: "https://example.com" }] },
executed: true,
})
const failedEvent = yield* db.get<{ type: string; data: string }>(sql`SELECT type, data FROM event WHERE id = 'evt_failed'`)
expect(failedEvent!.type).toBe("session.tool.failed.1")
expect(JSON.parse(failedEvent!.data)).toEqual({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
callID: "call_failed",
error: { type: "tool.execution", message: "timed out" },
metadata: { truncated: false },
executed: false,
})
expect(yield* db.get(sql`SELECT data FROM session_message WHERE id = 'msg_user'`)).toEqual({
data: '{"text":"hi","time":{"created":1}}',
})
expect(yield* db.get(sql`SELECT data FROM session_message WHERE id = 'msg_corrupt'`)).toEqual({
data: "not json",
})
}),
)
})
test("records the authoritative parent sequence on existing forks", async () => {
await run(
Effect.gen(function* () {
+4 -7
View File
@@ -14,7 +14,7 @@ export const toolIdentity = {
}
export const toolDefinitions = (registry: ToolRegistry.Interface, permissions?: PermissionV2.Ruleset) =>
registry.materialize(permissions).pipe(Effect.map((materialized) => materialized.definitions))
registry.snapshot(permissions).pipe(Effect.map((toolSet) => toolSet.definitions))
export function waitForTool(
registry: ToolRegistry.Interface,
@@ -35,7 +35,7 @@ export function waitForTool(
/**
* Registers a core tool plugin's tools against the real registry without booting the
* full plugin host. Only the tool domain is live; focused tool tests exercise
* registration, materialization, and settlement through the same path production uses.
* registration, snapshots, and execution through the same path production uses.
*/
export const registerToolPlugin = <R>(plugin: {
readonly id: string
@@ -52,7 +52,7 @@ export const registerToolPlugin = <R>(plugin: {
Effect.gen(function* () {
const registrations: Array<{
readonly name: string
readonly tool: Tool.AnyTool
readonly tool: Tool.Any
readonly options?: Tool.RegisterOptions
}> = []
callback({
@@ -73,8 +73,5 @@ export const registerToolPlugin = <R>(plugin: {
yield* plugin.effect(context)
})
export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) =>
registry.materialize().pipe(Effect.flatMap((materialized) => materialized.settle(input)))
export const executeTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) =>
settleTool(registry, input).pipe(Effect.map((settlement) => settlement.result))
registry.snapshot().pipe(Effect.flatMap((toolSet) => toolSet.execute(input)))

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