Compare commits

..

9 Commits

Author SHA1 Message Date
Kit Langton dd86e47762 test(core): remove directory search regression test 2026-08-04 21:42:55 -04:00
Kit Langton cdb51c6c29 fix(core): avoid eager directory snapshots 2026-08-04 21:38:16 -04:00
Kit Langton ab20a7b4a3 feat(tui): streamline tab navigation shortcuts (#40551) 2026-08-05 01:36:23 +00:00
opencode-agent[bot] ad9a95f6ee fix(ai): preserve Gemini tool finish semantics (#40546)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 20:29:06 -05:00
Aiden Cline 4ed343706d fix: retry empty incomplete streams (#40535) 2026-08-04 19:38:56 -05:00
opencode-agent[bot] 6d2eb2240a fix(app): finish tool call ID rename (#40539)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 19:26:45 -05:00
Dax Raad 53b8111121 refactor: rename tool call ID fields 2026-08-04 17:08:12 -04:00
Kit Langton 1fd817ffcf fix(client): wait for live service contenders (#40515) 2026-08-04 20:23:57 +00:00
Aiden Cline b9c118e0fe chore(core): drop unreachable AI SDK provider packages (#40504) 2026-08-04 14:37:56 -05:00
103 changed files with 578 additions and 1007 deletions
+20 -33
View File
@@ -88,6 +88,7 @@
"@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
@@ -284,13 +285,9 @@
"name": "@opencode-ai/console-function",
"version": "1.18.8",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.37",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode-ai/console-core": "workspace:*",
"@opencode-ai/console-resource": "workspace:*",
"ai": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
@@ -298,7 +295,6 @@
"@tsconfig/node22": "22.0.2",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"openai": "5.11.0",
"typescript": "catalog:",
},
},
@@ -361,18 +357,15 @@
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@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",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.102",
"@aws-sdk/credential-providers": "3.1057.0",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
@@ -386,7 +379,6 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"@standard-schema/spec": "catalog:",
@@ -911,7 +903,6 @@
"@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"get-east-asian-width": "catalog:",
@@ -1038,7 +1029,6 @@
"@shikijs/transformers": "3.20.0",
"@solid-primitives/resize-observer": "2.1.5",
"@types/luxon": "catalog:",
"ai": "catalog:",
"astro": "5.7.13",
"diff": "catalog:",
"js-base64": "3.7.7",
@@ -1084,17 +1074,16 @@
"tree-sitter-bash",
],
"patchedDependencies": {
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
"@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",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.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",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
},
"overrides": {
"@opentui/core": "catalog:",
@@ -1220,9 +1209,9 @@
"@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=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="],
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/perplexity": ["@ai-sdk/perplexity@3.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-dXzrVsLR5f6tr+U04jq4AXoRroGFBTvODnLgss0SWbzNjGGQg3XqtQ9j7rCLo6o8qbYGuAHvqUrIpUCuiscuFg=="],
@@ -4944,7 +4933,7 @@
"open": ["open@10.1.2", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="],
"openai": ["openai@5.11.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-+AuTc5pVjlnTuA9zvn8rA/k+1RluPIx9AD4eDcnutv6JNwHHZxIhkFy+tmMKCvmMFDQzfA/r1ujvPWB19DQkYg=="],
"openai": ["openai@6.49.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "ws", "zod"] }, "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
@@ -5962,12 +5951,8 @@
"@actions/http-client/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
@@ -5982,6 +5967,8 @@
"@ai-sdk/azure/@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=="],
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/cerebras/@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/cohere/@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=="],
@@ -5990,6 +5977,8 @@
"@ai-sdk/deepgram/@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/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/deepinfra/@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/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
@@ -6024,14 +6013,18 @@
"@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=="],
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/openai-compatible/@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/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/perplexity/@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/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/togetherai/@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/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/vercel/@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/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cQrN6OUn/jvsY3OdsU6Wn+ss7vp1iwIcakZKSlSRMnYqShBfyT7Qht+eqmgxs7w9ttrw6FAG6o11AiBs+iEsTA=="],
@@ -6350,10 +6343,6 @@
"@opencode-ai/console-mail/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="],
"@opencode-ai/core/@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=="],
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="],
"@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
@@ -6578,6 +6567,8 @@
"babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
"blume/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"blume/@astrojs/mdx": ["@astrojs/mdx@7.0.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.10.1", "@astrojs/markdown-remark": "7.2.1", "@mdx-js/mdx": "^3.1.1", "acorn": "^8.16.0", "es-module-lexer": "^2.0.0", "estree-util-visit": "^2.0.0", "hast-util-to-html": "^9.0.5", "piccolore": "^0.1.3", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-smartypants": "^3.0.2", "source-map": "^0.7.6", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@astrojs/markdown-satteri": "^0.3.1", "astro": "^7.0.0" }, "optionalPeers": ["@astrojs/markdown-satteri"] }, "sha512-RxyIwU0uFam5ftwqKOjpIdhnFxZ/kEikeimLyQy3eGXbHT8WgRGzzesOIHVU8+m9TY8ag5WVOyvV24/GyqPdPQ=="],
"blume/@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="],
@@ -6714,8 +6705,6 @@
"gcp-metadata/google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
"gitlab-ai-provider/openai": ["openai@6.49.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "ws", "zod"] }, "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg=="],
"gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -7380,10 +7369,6 @@
"@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@opencode-ai/core/@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=="],
"@opencode-ai/desktop/@actions/artifact/@actions/core": ["@actions/core@1.11.1", "", { "dependencies": { "@actions/exec": "^1.1.1", "@actions/http-client": "^2.0.1" } }, "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A=="],
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
@@ -7620,6 +7605,8 @@
"babel-plugin-module-resolver/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"blume/@ai-sdk/openai-compatible/@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=="],
"blume/@astrojs/mdx/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.10.1", "", { "dependencies": { "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "js-yaml": "^4.1.1", "picomatch": "^4.0.4", "retext-smartypants": "^6.2.0", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "unified": "^11.0.5" } }, "sha512-5phcroT/vmOOrYuuAxtkbPixy5hePtlz9i8K4OeDv3dNK6/UQRuXPOSRTxIOBbUY5Sonw2UaxjbuVc43Mcir6Q=="],
"blume/@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@7.2.1", "", { "dependencies": { "@astrojs/internal-helpers": "0.10.1", "@astrojs/prism": "4.0.2", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-jPVNIqTvk+yKviikszv/Y1U4jGUSKpp/Nw48QZV4qjWgp70j4Lkq3lhSDRbWwCfgKvEyO9GHuVbV1dM2WYXy1w=="],
-1
View File
@@ -161,7 +161,6 @@
"@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",
+1
View File
@@ -444,6 +444,7 @@ const mapUsage = (usage: GeminiUsage | undefined) => {
}
const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean): FinishReason => {
if (finishReason === undefined) return hasToolCalls ? "tool-calls" : "unknown"
if (finishReason === "STOP") return hasToolCalls ? "tool-calls" : "stop"
if (finishReason === "MAX_TOKENS") return "length"
if (
+14 -5
View File
@@ -20,6 +20,7 @@ import {
LanguageModel,
LanguageModelLimits,
LLMEvent,
InvalidProviderOutputReason,
ProviderID,
mergeGenerationOptions,
mergeHttpOptions,
@@ -231,6 +232,17 @@ const streamError = (route: string, message: string, cause: Cause.Cause<unknown>
return ProviderShared.eventError(route, message, Cause.pretty(cause))
}
const incompleteStreamError = (route: string) =>
new AIError({
module: "LLMClient",
method: "stream",
reason: new InvalidProviderOutputReason({
classification: "incomplete-stream",
message: "The provider response ended unexpectedly.",
route,
}),
})
const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent, AIError>) =>
Stream.suspend(() => {
let terminal = false
@@ -247,7 +259,7 @@ const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent,
Effect.suspend(() =>
terminal
? Effect.void
: Effect.fail(ProviderShared.eventError(route, "Provider stream ended without a terminal finish event")),
: Effect.fail(incompleteStreamError(route)),
),
),
)
@@ -416,10 +428,7 @@ const generateWith = (stream: Interface["stream"]) =>
const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
const response = LLMResponse.complete(state)
if (response) return response
return yield* ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
"Provider stream ended without a terminal finish event",
)
return yield* incompleteStreamError(`${request.model.provider}/${request.model.route.id}`)
})
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError, Service> {
+1
View File
@@ -105,6 +105,7 @@ export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOut
)({
_tag: Schema.tag("InvalidProviderOutput"),
message: Schema.String,
classification: Schema.optional(Schema.Literals(["incomplete-stream"])),
route: Schema.optional(Schema.String),
raw: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata),
+8 -8
View File
@@ -24,7 +24,7 @@ export type ToolExecute<Parameters extends ToolSchema<any>, Success extends Tool
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
export interface ToolModelOutputInput<Parameters, Output> {
readonly callID: ToolCallPart["id"]
readonly id: ToolCallPart["id"]
readonly parameters: Parameters
readonly output: Output
}
@@ -59,7 +59,7 @@ export interface Definition<Parameters extends ToolSchema<any>, Success extends
/** @internal */
readonly _project: (
parameters: Schema.Schema.Type<Parameters>,
callID: ToolCallPart["id"],
id: ToolCallPart["id"],
output: unknown,
) => ToolOutputType
/** @internal */
@@ -173,8 +173,8 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
toStructuredOutput: config.toStructuredOutput,
_decode: Effect.succeed,
_encode: Effect.succeed,
_project: (parameters, callID, output) =>
project(config.toModelOutput, config.toStructuredOutput, parameters, callID, output),
_project: (parameters, id, output) =>
project(config.toModelOutput, config.toStructuredOutput, parameters, id, output),
_legacyResult: config.toModelOutput === undefined && config.toStructuredOutput === undefined,
_definition: new ToolDefinition({
name: "",
@@ -193,8 +193,8 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
toStructuredOutput: config.toStructuredOutput,
_decode: Schema.decodeUnknownEffect(config.parameters),
_encode: Schema.encodeEffect(config.success),
_project: (parameters, callID, output) =>
project(config.toModelOutput, config.toStructuredOutput, parameters, callID, output),
_project: (parameters, id, output) =>
project(config.toModelOutput, config.toStructuredOutput, parameters, id, output),
_legacyResult: false,
_definition: new ToolDefinition({
name: "",
@@ -239,12 +239,12 @@ const project = (
toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<Tool.Content>) | undefined,
toStructuredOutput: ((output: unknown) => unknown) | undefined,
parameters: unknown,
callID: ToolCallPart["id"],
id: ToolCallPart["id"],
output: unknown,
): ToolOutputType =>
ToolOutput.make(
toStructuredOutput?.(output) ?? output,
toModelOutput?.({ callID, parameters, output }) ??
toModelOutput?.({ id, parameters, output }) ??
(typeof output === "string" ? [{ type: "text", text: output }] : []),
)
+2 -2
View File
@@ -133,8 +133,8 @@ describe("llm route", () => {
Effect.gen(function* () {
const error = yield* (yield* LLMClient.Service).stream(request).pipe(Stream.runDrain, Effect.flip)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(error.message).toContain("Provider stream ended without a terminal finish event")
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput", classification: "incomplete-stream" })
expect(error.message).toContain("The provider response ended unexpectedly.")
}),
)
@@ -538,7 +538,8 @@ describe("Anthropic Messages route", () => {
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "Provider stream ended without a terminal finish event",
classification: "incomplete-stream",
message: "The provider response ended unexpectedly.",
})
}),
)
+28
View File
@@ -601,6 +601,34 @@ describe("Gemini route", () => {
}),
)
it.effect("maps tool calls without a finish reason", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(
Effect.provide(
fixedResponse(
sseEvents({
candidates: [
{
content: {
role: "model",
parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
},
},
],
usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1 },
}),
),
),
)
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: undefined })
}),
)
it.effect("assigns unique ids to multiple streamed tool calls", () =>
Effect.gen(function* () {
const body = sseEvents({
@@ -1136,9 +1136,12 @@ describe("OpenAI Chat route", () => {
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
])
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
expect(streamError.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(streamError.message).toContain("Provider stream ended without a terminal finish event")
expect(error.message).toContain("Provider stream ended without a terminal finish event")
expect(streamError.reason).toMatchObject({
_tag: "InvalidProviderOutput",
classification: "incomplete-stream",
})
expect(streamError.message).toContain("The provider response ended unexpectedly.")
expect(error.message).toContain("The provider response ended unexpectedly.")
}),
)
+1 -1
View File
@@ -169,7 +169,7 @@ describe("LLMClient tools", () => {
LLMEvent.toolCall({ id: "call_projected", name: "projected", input: { prefix: "count" } }),
)
expect(calls).toEqual([{ callID: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }])
expect(calls).toEqual([{ id: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }])
expect(dispatched.result).toEqual({ type: "text", value: "count:2" })
expect(dispatched.output).toEqual({ structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] })
expect(dispatched.events).toEqual([
+2 -2
View File
@@ -27,8 +27,8 @@ Tool.make({
parameters: Schema.Struct({ city: Schema.String }),
success: Schema.Struct({ forecast: Schema.NumberFromString }),
execute: () => Effect.succeed({ forecast: 1 }),
toModelOutput: ({ callID, parameters, output }) => [
{ type: "text", text: `${callID}:${parameters.city}:${output.forecast}` },
toModelOutput: ({ id, parameters, output }) => [
{ type: "text", text: `${id}:${parameters.city}:${output.forecast}` },
],
})
@@ -53,7 +53,7 @@ describe("normalizePermissionRequest", () => {
resources: ["README.md"],
save: ["*.md"],
metadata: { path: "README.md" },
source: { type: "tool", messageID: "message-1", callID: "call-1" },
source: { type: "tool", messageID: "message-1", id: "call-1" },
}),
).toEqual({
id: "permission-1",
@@ -48,7 +48,7 @@ export function normalizePermissionRequest(input: PermissionRequest | LegacyPerm
always: input.save ?? [],
metadata: input.metadata ?? {},
tool:
input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.callID } : undefined,
input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.id } : undefined,
}
}
+34 -2
View File
@@ -21,12 +21,24 @@ describe("adaptServerEvent", () => {
id: "evt_1",
created: 1,
type: "permission.asked",
data: { id: "perm_1", sessionID: "ses_1", action: "read", resources: ["src/**"] },
data: {
id: "perm_1",
sessionID: "ses_1",
action: "read",
resources: ["src/**"],
source: { type: "tool", messageID: "msg_1", id: "call_1" },
},
} as OpenCodeEvent
expect(adaptServerEvent(current)).toMatchObject({
type: "permission.asked",
properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] },
properties: {
id: "perm_1",
sessionID: "ses_1",
permission: "read",
patterns: ["src/**"],
tool: { messageID: "msg_1", callID: "call_1" },
},
current,
})
})
@@ -70,6 +82,26 @@ describe("coalesceServerEvents", () => {
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
})
test("coalesces current tool input deltas by tool ID", () => {
const current = (eventID: string, id: string, delta: string) =>
adaptServerEvent({
id: eventID,
created: 1,
type: "session.tool.input.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
} as OpenCodeEvent)
const result = coalesceServerEvents([
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
{ directory: "/repo", payload: current("evt_3", "call_2", "[]") },
])
expect(result).toHaveLength(2)
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
})
test("preserves event boundaries and distinct fields", () => {
const status = {
directory: "/repo",
+2 -2
View File
@@ -39,7 +39,7 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
metadata: event.data.metadata ?? {},
tool:
event.data.source?.type === "tool"
? { messageID: event.data.source.messageID, callID: event.data.source.callID }
? { messageID: event.data.source.messageID, callID: event.data.source.id }
: undefined,
},
current: event,
@@ -142,7 +142,7 @@ function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefine
function currentDeltaKey(event: CurrentDelta) {
if (event.type === "session.tool.input.delta")
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.callID}`
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.id}`
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
}
@@ -92,19 +92,19 @@ describe("v2 session reducer", () => {
...base,
id: "evt_tool_start",
type: "session.tool.input.started",
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", name: "bash" },
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", name: "bash" },
})
apply({
...base,
id: "evt_tool_delta",
type: "session.tool.input.delta",
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", delta: "{}" },
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", delta: "{}" },
})
apply({
...base,
id: "evt_tool_called",
type: "session.tool.called",
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", input: {}, executed: true },
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", input: {}, executed: true },
})
apply({
...base,
@@ -113,7 +113,7 @@ describe("v2 session reducer", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
callID: "call_1",
id: "call_1",
metadata: {},
content: [{ type: "text", text: "done" }],
executed: true,
@@ -241,13 +241,13 @@ export function createV2SessionReducer() {
case "session.tool.input.started":
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
...item,
content: item.content.some((content) => content.type === "tool" && content.id === event.data.callID)
content: item.content.some((content) => content.type === "tool" && content.id === event.data.id)
? item.content
: [
...item.content,
{
type: "tool",
id: event.data.callID,
id: event.data.id,
name: event.data.name,
state: { status: "streaming", input: "" },
time: { created: event.created },
@@ -255,17 +255,17 @@ export function createV2SessionReducer() {
],
}))
case "session.tool.input.delta":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) =>
tool.state.status === "streaming"
? { ...tool, state: { ...tool.state, input: tool.state.input + event.data.delta } }
: tool,
)
case "session.tool.input.ended":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) =>
tool.state.status === "streaming" ? { ...tool, state: { ...tool.state, input: event.data.text } } : tool,
)
case "session.tool.called":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => ({
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => ({
...tool,
executed: event.data.executed,
providerState: event.data.state,
@@ -274,7 +274,7 @@ export function createV2SessionReducer() {
time: { ...tool.time, ran: event.created },
}))
case "session.tool.progress":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) =>
tool.state.status === "running"
? {
...tool,
@@ -284,7 +284,7 @@ export function createV2SessionReducer() {
: tool,
)
case "session.tool.success":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => {
if (tool.state.status !== "running") return tool
return {
...tool,
@@ -302,7 +302,7 @@ export function createV2SessionReducer() {
}
})
case "session.tool.failed":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => {
if (tool.state.status !== "streaming" && tool.state.status !== "running") return tool
return {
...tool,
+14 -14
View File
@@ -72,7 +72,7 @@ export async function streamTurn(input: {
if (next.done) throw new Error("event stream disconnected during prompt execution")
const event = next.value
if (event.type === "permission.asked" && event.data.sessionID === input.sessionID) {
const tool = event.data.source?.callID ? tools.get(event.data.source.callID) : undefined
const tool = event.data.source?.id ? tools.get(event.data.source.id) : undefined
await replyPermission({
client: input.client,
connection: input.connection,
@@ -120,11 +120,11 @@ 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: {}, metadata: {}, content: [] })
tools.set(event.data.id, { name: event.data.name, input: {}, metadata: {}, content: [] })
await update({
sessionUpdate: "tool_call",
...pendingToolCall({
toolCallId: event.data.callID,
toolCallId: event.data.id,
toolName: event.data.name,
state: { input: {} },
cwd: input.cwd,
@@ -134,13 +134,13 @@ export async function streamTurn(input: {
}
if (event.type === "session.tool.called") {
assistantMessageID = event.data.assistantMessageID
const current = tools.get(event.data.callID) ?? emptyToolState()
const current = tools.get(event.data.id) ?? emptyToolState()
current.input = event.data.input
tools.set(event.data.callID, current)
tools.set(event.data.id, current)
await update({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.callID,
toolCallId: event.data.id,
toolName: current.name,
state: { input: current.input },
cwd: input.cwd,
@@ -149,13 +149,13 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.tool.progress") {
const current = tools.get(event.data.callID)
const current = tools.get(event.data.id)
if (!current) continue
current.metadata = event.data.metadata
await update({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.callID,
toolCallId: event.data.id,
toolName: current.name,
state: { input: current.input },
cwd: input.cwd,
@@ -164,8 +164,8 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.tool.success") {
const current = tools.get(event.data.callID) ?? emptyToolState()
tools.delete(event.data.callID)
const current = tools.get(event.data.id) ?? emptyToolState()
tools.delete(event.data.id)
await syncEditedFiles({
connection: input.connection,
writeTextFile: input.writeTextFile,
@@ -178,7 +178,7 @@ export async function streamTurn(input: {
await update({
sessionUpdate: "tool_call_update",
...completedToolUpdate({
toolCallId: event.data.callID,
toolCallId: event.data.id,
toolName: current.name,
input: current.input,
metadata: event.data.metadata,
@@ -188,12 +188,12 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.tool.failed") {
const current = tools.get(event.data.callID) ?? emptyToolState()
tools.delete(event.data.callID)
const current = tools.get(event.data.id) ?? emptyToolState()
tools.delete(event.data.id)
await update({
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: event.data.callID,
toolCallId: event.data.id,
toolName: current.name,
input: current.input,
metadata: event.data.metadata ?? current.metadata,
+1 -1
View File
@@ -31,7 +31,7 @@ export async function replyPermission(input: {
sessionId: input.sessionID,
toolCall: {
...pendingToolCall({
toolCallId: input.event.data.source?.callID ?? input.event.data.id,
toolCallId: input.event.data.source?.id ?? input.event.data.id,
toolName,
state: { input: toolInput, title: permissionTitle(toolName, toolInput, previews) },
cwd: input.cwd,
+19 -19
View File
@@ -300,7 +300,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.tool.input.started") {
flushStep()
tools.set(toolKey(event.data.assistantMessageID, event.data.callID), {
tools.set(toolKey(event.data.assistantMessageID, event.data.id), {
id: partID(event.id),
timestamp: time,
assistantMessageID: event.data.assistantMessageID,
@@ -312,18 +312,18 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "session.tool.input.ended") {
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.id))
if (current) current.raw = event.data.text
continue
}
if (event.type === "session.tool.input.delta") {
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.id))
if (current) current.raw = (current.raw ?? "") + event.data.delta
continue
}
if (event.type === "session.tool.called") {
flushStep()
const key = toolKey(event.data.assistantMessageID, event.data.callID)
const key = toolKey(event.data.assistantMessageID, event.data.id)
const current = tools.get(key)
tools.set(key, {
id: current?.id ?? partID(event.id),
@@ -340,18 +340,18 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "session.tool.progress") {
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.id))
if (current) {
current.metadata = event.data.metadata
}
continue
}
if (event.type === "session.tool.success") {
const key = toolKey(event.data.assistantMessageID, event.data.callID)
const key = toolKey(event.data.assistantMessageID, event.data.id)
const current = tools.get(key) ?? fallbackTool(event)
const tool: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
id: event.data.id,
name: current.tool,
executed: event.data.executed,
providerState: current.providerState,
@@ -365,11 +365,11 @@ export async function runNonInteractivePrompt(input: Input) {
time: { created: current.timestamp, ran: current.timestamp, completed: time },
}
const part: MiniToolPart = {
id: current.id,
partID: current.id,
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "tool",
callID: event.data.callID,
id: event.data.id,
tool: current.tool,
state: {
status: "completed",
@@ -392,14 +392,14 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "session.tool.failed") {
const key = toolKey(event.data.assistantMessageID, event.data.callID)
const key = toolKey(event.data.assistantMessageID, event.data.id)
const current = tools.get(key) ?? fallbackTool(event)
const error = event.data.error.message
const metadata = event.data.metadata ?? current.metadata
const content = event.data.content ?? nonEmptyToolContent(current.content)
const tool: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
id: event.data.id,
name: current.tool,
executed: event.data.executed,
providerState: current.providerState,
@@ -414,11 +414,11 @@ export async function runNonInteractivePrompt(input: Input) {
time: { created: current.timestamp, ran: current.timestamp, completed: time },
}
const part: MiniToolPart = {
id: current.id,
partID: current.id,
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "tool",
callID: event.data.callID,
id: event.data.id,
tool: current.tool,
state: {
status: "error",
@@ -470,7 +470,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.step.failed") {
if (
input.compatibility === "v1" &&
event.data.error.message === "Provider stream ended without a terminal finish event"
event.data.error.message === "The provider response ended unexpectedly."
) {
pendingStep = undefined
v1InvalidOutput = true
@@ -578,11 +578,11 @@ export async function runNonInteractivePrompt(input: Input) {
const key = toolKey(message.id, item.id)
if (renderedTools.has(key) || item.state.status === "streaming" || item.state.status === "running") continue
const part: MiniToolPart = {
id: projectedPartID(message.id, `tool-${item.id}`),
partID: projectedPartID(message.id, `tool-${item.id}`),
sessionID: input.sessionID,
messageID: message.id,
type: "tool",
callID: item.id,
id: item.id,
tool: item.name,
state:
item.state.status === "completed"
@@ -771,8 +771,8 @@ function partID(eventID: string) {
return `prt_${eventID.replace(/^evt_/, "")}`
}
function toolKey(messageID: string, callID: string) {
return `${messageID}\u0000${callID}`
function toolKey(messageID: string, id: string) {
return `${messageID}\u0000${id}`
}
function contentKey(messageID: string, ordinal: number) {
@@ -786,7 +786,7 @@ function projectedPartID(messageID: string, part: string) {
function fallbackTool(event: {
id: string
created: number
data: { assistantMessageID: string; callID: string }
data: { assistantMessageID: string; id: string }
}): ToolState {
return {
id: partID(event.id),
+8 -8
View File
@@ -200,7 +200,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.input.started", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
id: "call_ok",
name: "shell",
}),
)
@@ -208,7 +208,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.called", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
id: "call_ok",
input: { command: "printf done", workdir: "sub" },
executed: false,
}),
@@ -217,7 +217,7 @@ describe("acp event behavior", () => {
ephemeralEvent("session.tool.progress", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
id: "call_ok",
metadata: { phase: 1 },
}),
)
@@ -225,7 +225,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.success", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
id: "call_ok",
metadata: { exit: 0 },
content: [{ type: "text", text: "done" }],
executed: true,
@@ -235,7 +235,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.input.started", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
id: "call_fail",
name: "read",
}),
)
@@ -243,7 +243,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.called", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
id: "call_fail",
input: { path: "/workspace/missing.ts" },
executed: false,
}),
@@ -252,7 +252,7 @@ describe("acp event behavior", () => {
ephemeralEvent("session.tool.progress", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
id: "call_fail",
metadata: { bytes: 0 },
}),
)
@@ -260,7 +260,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.failed", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
id: "call_fail",
error: { type: "tool.error", message: "not found" },
metadata: { bytes: 0 },
content: [{ type: "text", text: "opening" }],
@@ -43,14 +43,14 @@ describe("acp permission behavior", () => {
permissionAsked("ses_allow", "perm_once", {
action: "shell",
metadata: { command: "printf hello" },
source: { type: "tool", messageID: "msg_allow", callID: "call_once" },
source: { type: "tool", messageID: "msg_allow", id: "call_once" },
}),
)
send(
permissionAsked("ses_allow", "perm_always", {
action: "read",
metadata: { path: "/workspace/file.ts" },
source: { type: "tool", messageID: "msg_allow", callID: "call_always" },
source: { type: "tool", messageID: "msg_allow", id: "call_always" },
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_allow" }))
@@ -166,7 +166,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.input.started", {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
callID: "call_edit",
id: "call_edit",
name: "edit",
}),
)
@@ -174,7 +174,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.called", {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
callID: "call_edit",
id: "call_edit",
input: { path: "file.ts", oldString: "before", newString: "after" },
executed: false,
}),
@@ -182,7 +182,7 @@ describe("acp permission behavior", () => {
send(
permissionAsked("ses_edit", "perm_edit", {
action: "edit",
source: { type: "tool", messageID: "msg_edit", callID: "call_edit" },
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
}),
)
},
@@ -192,7 +192,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.success", {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
callID: "call_edit",
id: "call_edit",
metadata: { files: [{ file: "file.ts" }], replacements: 1 },
content: [{ type: "text", text: "edited" }],
executed: true,
@@ -256,7 +256,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.input.started", {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
callID: "call_patch",
id: "call_patch",
name: "patch",
}),
)
@@ -264,7 +264,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.called", {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
callID: "call_patch",
id: "call_patch",
input: { patchText },
executed: false,
}),
@@ -272,7 +272,7 @@ describe("acp permission behavior", () => {
send(
permissionAsked("ses_patch", "perm_patch", {
action: "edit",
source: { type: "tool", messageID: "msg_patch", callID: "call_patch" },
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
}),
)
},
@@ -285,7 +285,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.success", {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
callID: "call_patch",
id: "call_patch",
metadata: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
content: [{ type: "text", text: "patched" }],
executed: true,
@@ -499,7 +499,7 @@ function permissionAsked(
input: {
readonly action?: string
readonly metadata?: Record<string, unknown>
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
} = {},
) {
return ephemeralEvent("permission.asked", {
+10 -10
View File
@@ -109,7 +109,7 @@ function failedTool(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
id: "call_failed_tool",
name: "shell",
},
},
@@ -121,7 +121,7 @@ function failedTool(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
id: "call_failed_tool",
input: { command: "printf partial && false" },
executed: true,
},
@@ -133,7 +133,7 @@ function failedTool(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
id: "call_failed_tool",
metadata: { checkpoint: 1 },
},
},
@@ -145,7 +145,7 @@ function failedTool(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
id: "call_failed_tool",
error: { type: "unknown", message: "tool failed" },
metadata: { checkpoint: 1 },
content: [{ type: "text", text: "partial output" }],
@@ -168,7 +168,7 @@ function successfulGrep(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
callID: "call_grep",
id: "call_grep",
name: "grep",
},
},
@@ -180,7 +180,7 @@ function successfulGrep(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
callID: "call_grep",
id: "call_grep",
input: { pattern: "needle" },
executed: true,
},
@@ -193,7 +193,7 @@ function successfulGrep(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
callID: "call_grep",
id: "call_grep",
metadata: { matches: 2 },
content: [{ type: "text", text }],
executed: false,
@@ -503,8 +503,8 @@ describe("runNonInteractivePrompt", () => {
turn: (messageID) => [
prompted(messageID),
stepStarted(),
stepFailed("Provider stream ended without a terminal finish event"),
executionFailed("Provider stream ended without a terminal finish event"),
stepFailed("The provider response ended unexpectedly."),
executionFailed("The provider response ended unexpectedly."),
],
})
@@ -561,7 +561,7 @@ describe("runNonInteractivePrompt", () => {
type: "tool_use",
part: {
type: "tool",
callID: "call_failed_tool",
id: "call_failed_tool",
tool: "shell",
state: {
status: "error",
+5 -5
View File
@@ -605,7 +605,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly callID: string
readonly id: string
readonly name: string
}
}
@@ -619,7 +619,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly callID: string
readonly id: string
readonly text: string
}
}
@@ -633,7 +633,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly callID: string
readonly id: string
readonly input: { readonly [x: string]: unknown }
readonly executed: boolean
readonly state?: SessionMessage.ProviderState | undefined
@@ -649,7 +649,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly callID: string
readonly id: string
readonly content: readonly [
(
| { readonly type: "text"; readonly text: string }
@@ -685,7 +685,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly callID: string
readonly id: string
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly content?:
| readonly [
+2 -5
View File
@@ -56,7 +56,6 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
let announced = false
let lastSpawn = 0
let spawnDelay = 5_000
let ownerHeld = false
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
Effect.sync(() => {
if (announced) return
@@ -84,7 +83,6 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
const info = registration.info
const service = registration.service
if (service !== undefined) {
ownerHeld = false
spawnDelay = 5_000
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
if (compatible && service.state === "ready") return Option.some(service)
@@ -97,14 +95,13 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
const failure = [...contenders].map(contenderFailure).find((error): error is Error => error !== undefined)
if (failure !== undefined) return yield* Effect.fail(failure)
const finished = [...contenders].filter(contenderFinished)
const failure = finished.map(contenderFailure).find((error): error is Error => error !== undefined)
if (finished.some((item) => item.child.exitCode === 0)) {
ownerHeld = true
spawnDelay = Math.min(spawnDelay * 2, 30_000)
}
finished.forEach((item) => contenders.delete(item))
if (failure !== undefined && contenders.size === 0) return yield* Effect.fail(failure)
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
yield* announce("missing")
+16 -16
View File
@@ -297,7 +297,7 @@ export type FormExternalField = { key: string; type: "external"; url: string; ti
export type FormValue = string | number | boolean | Array<string>
export type PermissionSource = { type: "tool"; messageID: string; callID: string }
export type PermissionSource = { type: "tool"; messageID: string; id: string }
export type PermissionSavedInfo = { id: string; projectID: string; action: string; resource: string }
@@ -486,7 +486,7 @@ export type Pty = {
export type QuestionOption = { label: string; description: string }
export type QuestionTool = { messageID: string; callID: string }
export type QuestionTool = { messageID: string; id: string }
export type QuestionAnswer = Array<string>
@@ -744,7 +744,7 @@ export type SessionToolInputStarted = {
type: "session.tool.input.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; callID: string; name: string }
data: { sessionID: string; assistantMessageID: string; id: string; name: string }
}
export type SessionToolInputEnded = {
@@ -754,7 +754,7 @@ export type SessionToolInputEnded = {
type: "session.tool.input.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; callID: string; text: string }
data: { sessionID: string; assistantMessageID: string; id: string; text: string }
}
export type SessionCompactionAdmitted = {
@@ -915,7 +915,7 @@ export type SessionToolInputDelta = {
metadata?: { [x: string]: any }
type: "session.tool.input.delta"
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; callID: string; delta: string }
data: { sessionID: string; assistantMessageID: string; id: string; delta: string }
}
export type SessionToolProgress = {
@@ -924,7 +924,7 @@ export type SessionToolProgress = {
metadata?: { [x: string]: any }
type: "session.tool.progress"
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; callID: string; metadata: { [x: string]: JsonValue } }
data: { sessionID: string; assistantMessageID: string; id: string; metadata: { [x: string]: JsonValue } }
}
export type SessionCompactionDelta = {
@@ -1380,7 +1380,7 @@ export type SessionToolCalled = {
data: {
sessionID: string
assistantMessageID: string
callID: string
id: string
input: { [x: string]: any }
executed: boolean
state?: SessionMessageProviderState7
@@ -1831,7 +1831,7 @@ export type SessionToolSuccess = {
data: {
sessionID: string
assistantMessageID: string
callID: string
id: string
content: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
executed: boolean
@@ -1849,7 +1849,7 @@ export type SessionToolFailed = {
data: {
sessionID: string
assistantMessageID: string
callID: string
id: string
error: SessionStructuredError
content?: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
@@ -4472,7 +4472,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["id"]
readonly action: {
@@ -4481,7 +4481,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["action"]
readonly resources: {
@@ -4490,7 +4490,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["resources"]
readonly save?: {
@@ -4499,7 +4499,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["save"]
readonly metadata?: {
@@ -4508,7 +4508,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["metadata"]
readonly source?: {
@@ -4517,7 +4517,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["source"]
readonly agent?: {
@@ -4526,7 +4526,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["agent"]
}
+2 -5
View File
@@ -37,7 +37,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
let announced = false
let lastSpawn = 0
let spawnDelay = 5_000
let ownerHeld = false
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
if (announced) return
@@ -65,7 +64,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const registration = await registered(options.file, true)
if (registration.service !== undefined) {
ownerHeld = false
spawnDelay = 5_000
const service = registration.service
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
@@ -78,14 +76,13 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
}
} else {
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
const failure = [...contenders].map(contenderFailure).find((error) => error !== undefined)
if (failure !== undefined) throw failure
const finished = [...contenders].filter(contenderFinished)
const failure = finished.map(contenderFailure).find((error) => error !== undefined)
if (finished.some((item) => item.child.exitCode === 0)) {
ownerHeld = true
spawnDelay = Math.min(spawnDelay * 2, 30_000)
}
finished.forEach((item) => contenders.delete(item))
if (failure !== undefined && contenders.size === 0) throw failure
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
announce("missing")
+9 -3
View File
@@ -9,14 +9,20 @@ if (mode === "record-start") {
}
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated") {
if (
mode === "delayed" ||
mode === "delayed-failed" ||
mode === "coordinated" ||
mode === "coordinated-failed-loser"
) {
await appendFile(registration + ".starts", process.pid + "\n")
const owner = await writeFile(registration + ".owner", String(process.pid), { flag: "wx" })
.then(() => true)
.catch(() => false)
if (!owner) process.exit()
if (mode === "coordinated") {
if (!owner) process.exit(mode === "coordinated-failed-loser" ? 1 : 0)
if (mode === "coordinated" || mode === "coordinated-failed-loser") {
while ((await Bun.file(registration + ".starts").text()).trim().split("\n").length < 2) await Bun.sleep(10)
if (mode === "coordinated-failed-loser") await Bun.sleep(1_500)
} else await Bun.sleep(Number(delay))
if (mode === "delayed-failed") process.exit(1)
}
@@ -44,6 +44,24 @@ test("ensures a missing service with native promises", async () => {
}
}, 15_000)
test("waits for a live contender when another native contender fails", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const endpoint = await Service.ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "coordinated-failed-loser"],
})
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
} finally {
process.kill(info.pid, "SIGTERM")
await waitForExit(info.pid)
}
}, 15_000)
test("reports a failed registered service", async () => {
const registration = await setup("failed-owner")
+18
View File
@@ -141,6 +141,24 @@ test("waits for a slow winner while bounding lock probes", async () => {
}
}, 15_000)
test("waits for a live contender when another contender fails", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const endpoint = await run(
Service.ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "coordinated-failed-loser"],
}),
)
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
} finally {
process.kill(info.pid, "SIGTERM")
}
}, 15_000)
test("reports a contender that fails to start", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
-5
View File
@@ -12,18 +12,13 @@
"@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "22.0.2",
"@types/node": "catalog:",
"openai": "5.11.0",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
},
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.37",
"@opencode-ai/console-core": "workspace:*",
"@opencode-ai/console-resource": "workspace:*",
"@openauthjs/openauth": "0.0.0-20250322224806",
"ai": "catalog:",
"zod": "catalog:"
}
}
-4
View File
@@ -78,18 +78,15 @@
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@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",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.102",
"@aws-sdk/credential-providers": "3.1057.0",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
@@ -106,7 +103,6 @@
"@standard-schema/spec": "catalog:",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"@openrouter/ai-sdk-provider": "2.9.0",
"ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8",
"diff": "catalog:",
+5 -9
View File
@@ -31,10 +31,7 @@ export const ripgrepLayer = Layer.effect(
const location = yield* Location.Service
const ripgrep = yield* Ripgrep.Service
const scope = yield* Scope.Scope
const state = {
files: [] as string[],
directories: [] as string[],
}
const files: string[] = []
const directories = new Set<string>()
yield* ripgrep
.find({
@@ -43,10 +40,9 @@ export const ripgrepLayer = Layer.effect(
limit: location.vcs ? Number.MAX_SAFE_INTEGER : 100_000,
onEntry: (entry) =>
Effect.sync(() => {
state.files.push(entry.path)
files.push(entry.path)
const parts = entry.path.split("/")
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
state.directories = Array.from(directories)
}),
})
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
@@ -106,10 +102,10 @@ export const ripgrepLayer = Layer.effect(
Effect.gen(function* () {
const items =
input.type === "file"
? state.files
? files
: input.type === "directory"
? state.directories
: [...state.files, ...state.directories]
? Array.from(directories)
: [...files, ...directories]
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
const relative = item.target
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
-2
View File
@@ -11,7 +11,6 @@ import { DynamicProviderPlugin } from "./provider/dynamic"
import { GatewayPlugin } from "./provider/gateway"
import { GithubCopilotPlugin } from "./provider/github-copilot"
import { GitLabPlugin } from "./provider/gitlab"
import { GooglePlugin } from "./provider/google"
import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "./provider/google-vertex"
import { GroqPlugin } from "./provider/groq"
import { KiloPlugin } from "./provider/kilo"
@@ -46,7 +45,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
GatewayPlugin,
GithubCopilotPlugin,
GitLabPlugin,
GooglePlugin,
GoogleVertexAnthropicPlugin,
GoogleVertexPlugin,
GroqPlugin,
@@ -1,16 +0,0 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
export const GooglePlugin = define({
id: "opencode.provider.google",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/google") return
const mod = yield* Effect.promise(() => import("@ai-sdk/google"))
evt.sdk = mod.createGoogleGenerativeAI(evt.options)
}),
)
}),
})
@@ -23,13 +23,5 @@ export const OpenRouterPlugin = define({
}
}
})
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@openrouter/ai-sdk-provider") return
const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider"))
evt.sdk = mod.createOpenRouter(evt.options)
}),
)
}),
})
-16
View File
@@ -6,7 +6,6 @@ import { App } from "../../app"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { OauthCallbackPage } from "../../oauth/page"
import { Provider } from "../../provider"
const clientID = "b1a00492-073a-47ea-816f-4c329264a828"
const issuer = "https://auth.x.ai/oauth2"
@@ -162,21 +161,6 @@ export const XAIPlugin = define({
draft.method.update(device(ctx.app))
draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
})
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/xai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/xai"))
evt.sdk = mod.createXai(evt.options)
}),
)
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
if (evt.model.providerID !== Provider.ID.make("xai")) return
evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)
}),
)
}),
})
+8 -8
View File
@@ -111,9 +111,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
type DraftText = WritableDraft<SessionMessage.AssistantText>
type DraftReasoning = WritableDraft<SessionMessage.AssistantReasoning>
const latestTool = (assistant: DraftAssistant | undefined, callID?: string) =>
const latestTool = (assistant: DraftAssistant | undefined, id?: string) =>
assistant?.content.findLast(
(item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID),
(item): item is DraftTool => item.type === "tool" && (id === undefined || item.id === id),
)
const latestText = (assistant: DraftAssistant | undefined) =>
@@ -331,7 +331,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
castDraft(
SessionMessage.AssistantTool.make({
type: "tool",
id: event.data.callID,
id: event.data.id,
name: event.data.name,
time: { created: event.created },
state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }),
@@ -343,13 +343,13 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.tool.input.delta": () => Effect.void,
"session.tool.input.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
const match = latestTool(draft, event.data.id)
if (match && match.state.status === "streaming") match.state.input = event.data.text
})
},
"session.tool.called": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
const match = latestTool(draft, event.data.id)
if (match) {
match.executed = event.data.executed
match.providerState = event.data.state
@@ -366,7 +366,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
},
"session.tool.progress": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
const match = latestTool(draft, event.data.id)
if (match && match.state.status === "running") {
match.state.metadata = event.data.metadata
}
@@ -376,7 +376,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
// never reaches into ephemeral progress history.
"session.tool.success": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
const match = latestTool(draft, event.data.id)
if (match && match.state.status === "running") {
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
@@ -394,7 +394,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
},
"session.tool.failed": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
const match = latestTool(draft, event.data.id)
if (match && (match.state.status === "streaming" || match.state.status === "running")) {
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
+1 -1
View File
@@ -488,7 +488,7 @@ const layer = Layer.effect(
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID,
assistantMessageID: message.id,
callID: tool.id,
id: tool.id,
error: { type: "aborted", message: `Tool execution interrupted: ${tool.name}` },
executed: tool.executed === true,
})
@@ -78,7 +78,7 @@ const hostedContent = (result: ToolResultValue): NonEmptyContent => {
* between a check (`tool.settled`, `stepStarted`, ...) and its mark, so check-and-mark
* stays atomic under cooperative scheduling. (2) Never require a cross-source event
* order: each publishing fiber is sequential, so per-source order holds by construction,
* and consumers fold by callID/ordinal rather than global position.
* and consumers fold by id/ordinal rather than global position.
*/
export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, input: Input) => {
const tools = new Map<
@@ -188,14 +188,14 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
}),
true,
)
const toolInput = fragments("tool input", (callID, value) =>
const toolInput = fragments("tool input", (id, value) =>
Effect.gen(function* () {
const tool = tools.get(callID)
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${callID}`))
const tool = tools.get(id)
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${id}`))
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
id,
text: value,
})
}),
@@ -225,7 +225,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID: input.sessionID,
assistantMessageID,
callID: event.id,
id: event.id,
name: event.name,
})
})
@@ -258,7 +258,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
id: event.id,
error: {
type: "tool.input-json",
message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.",
@@ -272,14 +272,14 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* flushFragments()
})
const failTool = Effect.fnUntraced(function* (callID: string, error: SessionError.Error) {
const tool = tools.get(callID)
const failTool = Effect.fnUntraced(function* (id: string, error: SessionError.Error) {
const tool = tools.get(id)
if (!tool || tool.settled) return false
tool.settled = true
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
id,
error,
...failureSnapshot(tool),
executed: tool.providerExecuted,
@@ -289,10 +289,10 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
const failTools = Effect.fnUntraced(function* (error: SessionError.Error, mode: "all" | "hosted" | "uncalled") {
let failed = false
for (const [callID, tool] of tools) {
for (const [id, tool] of tools) {
if (tool.settled || (mode === "hosted" && !tool.providerExecuted) || (mode === "uncalled" && tool.called))
continue
failed = (yield* failTool(callID, error)) || failed
failed = (yield* failTool(id, error)) || failed
}
return failed
})
@@ -328,9 +328,9 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
return yield* failTools(error, scope)
})
const assistantMessageIDForTool = (callID: string) => {
const tool = tools.get(callID)
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`))
const assistantMessageIDForTool = (id: string) => {
const tool = tools.get(id)
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${id}`))
}
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent) {
@@ -399,7 +399,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Input.Delta, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
id: event.id,
delta: event.text,
})
return
@@ -424,7 +424,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Called, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
id: event.id,
input: asRecord(event.input),
executed: tool.providerExecuted,
state: providerState(event.providerMetadata),
@@ -450,7 +450,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
id: event.id,
error: { type: "tool.execution", message: stringify(event.result.value) },
...failureSnapshot(tool),
executed,
@@ -461,7 +461,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
id: event.id,
content: hostedContent(event.result),
executed,
resultState,
@@ -478,7 +478,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
id: event.id,
error:
event.message === `Unknown tool: ${event.name}`
? { type: "tool.unknown", message: event.message }
@@ -508,30 +508,30 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
}
})
const progress = Effect.fnUntraced(function* (callID: string, update: Tool.Metadata) {
const tool = tools.get(callID)
const progress = Effect.fnUntraced(function* (id: string, update: Tool.Metadata) {
const tool = tools.get(id)
if (!tool?.called || tool.settled)
return yield* Effect.die(new Error(`Tool progress outside running call: ${callID}`))
return yield* Effect.die(new Error(`Tool progress outside running call: ${id}`))
tool.progress = update
yield* bus.publish(SessionEvent.Tool.Progress, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
id,
metadata: update,
})
})
/** Publishes one canonical terminal event for a locally executed tool call. */
const toolExecution = Effect.fnUntraced(function* (
callID: string,
id: string,
name: string,
result: Tool.Result,
) {
const tool = tools.get(callID)
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${callID}`))
const tool = tools.get(id)
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${id}`))
if (tool.name !== name)
return yield* Effect.die(new Error(`Tool execution name changed for ${callID}: ${tool.name} -> ${name}`))
if (tool.settled) return yield* Effect.die(new Error(`Duplicate tool execution: ${callID}`))
return yield* Effect.die(new Error(`Tool execution name changed for ${id}: ${tool.name} -> ${name}`))
if (tool.settled) return yield* Effect.die(new Error(`Duplicate tool execution: ${id}`))
tool.settled = true
const content =
typeof result.content === "string"
@@ -539,11 +539,11 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
: result.content === undefined
? []
: [...result.content]
if (content.length === 0) return yield* Effect.die(new Error(`Tool execution has no content: ${callID}`))
if (content.length === 0) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
yield* bus.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
id,
content: [content[0], ...content.slice(1)],
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
executed: tool.providerExecuted,
+2 -1
View File
@@ -20,10 +20,11 @@ export function isRetryable(error: AIError) {
case "ProviderInternal":
case "Transport":
return true
case "InvalidProviderOutput":
return error.reason.classification === "incomplete-stream"
case "Authentication":
case "QuotaExceeded":
case "ContentPolicy":
case "InvalidProviderOutput":
case "InvalidRequest":
case "NoRoute":
case "UnknownProvider":
+3 -3
View File
@@ -93,7 +93,7 @@ const layer = Layer.effect(
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
callID: context.callID,
id: context.id,
input,
}
yield* hooks.trigger("tool", "execute.before", beforeEvent)
@@ -106,7 +106,7 @@ const layer = Layer.effect(
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
callID: context.callID,
id: context.id,
input: beforeEvent.input,
}
if ("failure" in execution) {
@@ -228,7 +228,7 @@ const layer = Layer.effect(
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
callID: Tool.CallID.make(input.call.id),
id: Tool.CallID.make(input.call.id),
progress: input.progress ?? (() => Effect.void),
}
if (input.call.name === "execute" && codemodeTool)
+1 -1
View File
@@ -22,7 +22,7 @@ Location-scoped built-in layers acquire `Permission.Service` and every other req
const source = {
type: "tool" as const,
messageID: context.messageID,
callID: context.callID,
id: context.id,
}
```
+1 -1
View File
@@ -59,7 +59,7 @@ export const layer = Layer.effectDiscard(
source: {
type: "tool",
messageID: context.messageID,
callID: context.callID,
id: context.id,
},
})
const result = yield* mcp
+1 -1
View File
@@ -129,7 +129,7 @@ export const Plugin = {
const permissionSource = {
type: "tool" as const,
messageID: context.messageID,
callID: context.callID,
id: context.id,
}
if (input.oldString === input.newString) {
return yield* new ToolFailure({
+1 -1
View File
@@ -61,7 +61,7 @@ export const Plugin = {
execute: (input, context) =>
Effect.gen(function* () {
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
const source = { type: "tool" as const, messageID: context.messageID, callID: context.callID }
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" })
const external = target.externalDirectory
if (external)
+1 -1
View File
@@ -76,7 +76,7 @@ export const Plugin = {
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const source = { type: "tool" as const, messageID: context.messageID, callID: context.callID }
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const target = yield* mutation.resolve({ path: input.path ?? "." })
if (target.externalDirectory)
yield* permission.assert({
+1 -1
View File
@@ -95,7 +95,7 @@ export const Plugin = {
const source = {
type: "tool" as const,
messageID: context.messageID,
callID: context.callID,
id: context.id,
}
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
+2 -2
View File
@@ -70,7 +70,7 @@ export const Plugin = {
resources: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, callID: context.callID },
source: { type: "tool", messageID: context.messageID, id: context.id },
})
.pipe(
Effect.mapError((error) => new ToolFailure({ message: "Permission denied: question", error })),
@@ -81,7 +81,7 @@ export const Plugin = {
title: "Questions",
metadata: {
kind: "question",
tool: { messageID: context.messageID, callID: context.callID },
tool: { messageID: context.messageID, id: context.id },
},
fields: [
toField(input.questions[0], 0),
+1 -1
View File
@@ -56,7 +56,7 @@ export const Plugin = {
const source = {
type: "tool" as const,
messageID: context.messageID,
callID: context.callID,
id: context.id,
}
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
const external = target.externalDirectory
+7 -7
View File
@@ -89,10 +89,10 @@ export const Plugin = {
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
sessionID: SessionSchema.ID,
callID: string,
id: string,
command: string,
) {
yield* runtime.job.wait({ id: callID }).pipe(
yield* runtime.job.wait({ id: id }).pipe(
Effect.flatMap((result) => {
const state =
result.info?.status === "completed"
@@ -111,7 +111,7 @@ export const Plugin = {
: "Command cancelled"
return runtime.session.synthetic({
sessionID,
text: `<shell id="${callID}" state="${state}" command="${command}">\n${text}\n</shell>`,
text: `<shell id="${id}" state="${state}" command="${command}">\n${text}\n</shell>`,
description: command,
metadata: { source: "shell", state },
})
@@ -134,7 +134,7 @@ export const Plugin = {
const source = {
type: "tool" as const,
messageID: context.messageID,
callID: context.callID,
id: context.id,
}
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
let finalTimeout = timeout
@@ -227,7 +227,7 @@ export const Plugin = {
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
const job = yield* runtime.job.start({
id: context.callID,
id: context.id,
type: name,
title: info.command,
metadata: { sessionID: context.sessionID, shellID: info.id },
@@ -236,7 +236,7 @@ export const Plugin = {
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.callID, info.command)
yield* notifyWhenDone(context.sessionID, context.id, info.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
@@ -250,7 +250,7 @@ export const Plugin = {
)
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.callID, info.command)
yield* notifyWhenDone(context.sessionID, context.id, info.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
+1 -1
View File
@@ -75,7 +75,7 @@ export const Plugin = {
save: [skill.id],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, callID: context.callID },
source: { type: "tool", messageID: context.messageID, id: context.id },
})
const directory = path.dirname(skill.location)
const files =
+1 -1
View File
@@ -159,7 +159,7 @@ export const Plugin = {
source: {
type: "tool",
messageID: context.messageID,
callID: context.callID,
id: context.id,
},
})
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
+1 -1
View File
@@ -139,7 +139,7 @@ export const Plugin = {
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, callID: context.callID },
source: { type: "tool", messageID: context.messageID, id: context.id },
})
const { body, contentType } = yield* Effect.gen(function* () {
+1 -1
View File
@@ -47,7 +47,7 @@ export const Plugin = {
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, callID: context.callID },
source: { type: "tool", messageID: context.messageID, id: context.id },
})
const result = yield* ctx.websearch.query(input).pipe(
Effect.catch((error) => {
+1 -1
View File
@@ -67,7 +67,7 @@ export const Plugin = {
const source = {
type: "tool" as const,
messageID: context.messageID,
callID: context.callID,
id: context.id,
}
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
const external = target.externalDirectory
@@ -714,7 +714,7 @@ describe("DatabaseMigration", () => {
sql`INSERT INTO event VALUES ('evt_success', 'session.tool.success.1', ${JSON.stringify({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
callID: "call_hosted",
id: "call_hosted",
structured: {},
content: [],
result: { type: "json", value: [{ url: "https://example.com" }] },
@@ -725,7 +725,7 @@ describe("DatabaseMigration", () => {
sql`INSERT INTO event VALUES ('evt_failed', 'session.tool.failed.1', ${JSON.stringify({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
callID: "call_failed",
id: "call_failed",
error: { type: "tool.execution", message: "timed out" },
metadata: { truncated: false },
executed: false,
@@ -795,7 +795,7 @@ describe("DatabaseMigration", () => {
expect(JSON.parse(event!.data)).toEqual({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
callID: "call_hosted",
id: "call_hosted",
structured: {},
content: [],
result: { type: "json", value: [{ url: "https://example.com" }] },
@@ -806,7 +806,7 @@ describe("DatabaseMigration", () => {
expect(JSON.parse(failedEvent!.data)).toEqual({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
callID: "call_failed",
id: "call_failed",
error: { type: "tool.execution", message: "timed out" },
metadata: { truncated: false },
executed: false,
+1 -1
View File
@@ -928,7 +928,7 @@ it.effect("waits for permission before calling an MCP tool", () =>
source: {
type: "tool",
messageID: toolIdentity.messageID,
callID: "call_mcp_permission",
id: "call_mcp_permission",
},
})
expect(calls).toBe(0)
@@ -1,103 +0,0 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* GooglePlugin.effect(host)
})
describe("GooglePlugin", () => {
it.effect("creates a Google Generative AI SDK for @ai-sdk/google using the provider ID as SDK name", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-google"), Model.ID.make("gemini")),
modelID: Model.ID.make("gemini"),
package: "aisdk:@ai-sdk/google",
}),
package: "@ai-sdk/google",
options: { name: "custom-google", apiKey: "test" },
})
expect(result.sdk).toBeDefined()
expect(result.sdk?.languageModel("gemini").provider).toBe("custom-google")
}),
)
it.effect("ignores non-Google SDK packages", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("google"), Model.ID.make("gemini")),
modelID: Model.ID.make("gemini"),
package: "aisdk:@ai-sdk/google",
}),
package: "@ai-sdk/google-vertex",
options: { name: "google" },
})
expect(result.sdk).toBeUndefined()
}),
)
it.effect("uses default languageModel loading with provider ID parity", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const sdkEvent = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-google"), Model.ID.make("alias")),
modelID: Model.ID.make("gemini-api"),
package: "aisdk:@ai-sdk/google",
}),
package: "@ai-sdk/google",
options: { name: "custom-google", apiKey: "test" },
})
const result = yield* aisdk.runLanguage({
model: sdkEvent.model,
sdk: sdkEvent.sdk,
options: sdkEvent.options,
})
const language = result.language ?? result.sdk.languageModel(result.model.modelID ?? result.model.id)
expect(language.modelId).toBe("gemini-api")
expect(language.provider).toBe("custom-google")
}),
)
it.effect("wraps AI SDK language models for the native runner", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* addPlugin()
const resolved = yield* aisdk.model(
Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-google"), Model.ID.make("alias")),
modelID: Model.ID.make("gemini-api"),
package: "aisdk:@ai-sdk/google",
settings: { apiKey: "test" },
}),
)
expect(String(resolved.id)).toBe("gemini-api")
expect(String(resolved.provider)).toBe("custom-google")
expect(resolved.route.id).toBe("ai-sdk:@ai-sdk/google")
}),
)
})
@@ -1,4 +1,3 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
@@ -15,7 +14,6 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* OpenRouterPlugin.effect(host)
})
@@ -46,36 +44,6 @@ describe("OpenRouterPlugin", () => {
}),
)
it.effect("creates an SDK only for the OpenRouter package", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const ignored = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.openrouter, Model.ID.make("openai/gpt-5")),
modelID: Model.ID.make("openai/gpt-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/openai-compatible",
options: { name: "openrouter" },
})
expect(ignored.sdk).toBeUndefined()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom"), Model.ID.make("openai/gpt-5")),
modelID: Model.ID.make("openai/gpt-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@openrouter/ai-sdk-provider",
options: { name: "custom" },
})
expect(result.sdk).toBeDefined()
}),
)
it.effect("filters OpenRouter's gpt-5 chat alias", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -1,13 +1,9 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Integration } from "@opencode-ai/core/integration"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -19,19 +15,6 @@ const addPlugin = Effect.fn(function* () {
yield* XAIPlugin.effect(host)
})
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
describe("XAIPlugin", () => {
it.effect("registers browser OAuth, device OAuth, and API key methods", () =>
Effect.gen(function* () {
@@ -54,95 +37,4 @@ describe("XAIPlugin", () => {
])
}),
)
it.effect("creates an xAI SDK only for @ai-sdk/xai", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* addPlugin()
const ignored = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("xai"), Model.ID.make("grok-4")),
modelID: Model.ID.make("grok-4"),
package: "aisdk:@ai-sdk/xai",
}),
package: "@ai-sdk/openai-compatible",
options: {},
})
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("xai"), Model.ID.make("grok-4")),
modelID: Model.ID.make("grok-4"),
package: "aisdk:@ai-sdk/xai",
}),
package: "@ai-sdk/xai",
options: {},
})
expect(ignored.sdk).toBeUndefined()
expect(typeof result.sdk?.responses).toBe("function")
}),
)
it.effect("creates xAI SDKs for custom provider IDs", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-xai"), Model.ID.make("grok-4")),
modelID: Model.ID.make("grok-4"),
package: "aisdk:@ai-sdk/xai",
}),
package: "@ai-sdk/xai",
options: {},
})
expect(result.sdk.responses("grok-4").provider).toBe("xai.responses")
}),
)
it.effect("uses responses with the model modelID for xAI language models", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("xai"), Model.ID.make("alias")),
modelID: Model.ID.make("grok-4"),
package: "aisdk:@ai-sdk/xai",
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
expect(calls).toEqual(["responses:grok-4"])
expect(result.language).toBeDefined()
}),
)
it.effect("ignores non-xAI providers", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: Model.Info.make({
...Model.Info.default(Provider.ID.openai, Model.ID.make("grok-4")),
modelID: Model.ID.make("grok-4"),
package: "aisdk:@ai-sdk/xai",
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
expect(calls).toEqual([])
expect(result.language).toBeUndefined()
}),
)
})
@@ -1,32 +0,0 @@
import { createXai } from "@ai-sdk/xai"
import { expect, test } from "bun:test"
test("xAI Responses sends promptCacheKey as prompt_cache_key", async () => {
let body: Record<string, unknown> | undefined
const mockFetch = Object.assign(
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
body = JSON.parse(String(init?.body))
return Response.json({
id: "response-1",
created_at: 0,
model: "grok-4",
object: "response",
output: [],
usage: { input_tokens: 1, output_tokens: 0 },
status: "completed",
})
},
{ preconnect: fetch.preconnect },
)
const model = createXai({
apiKey: "test",
fetch: mockFetch,
}).responses("grok-4")
await model.doGenerate({
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
providerOptions: { xai: { promptCacheKey: "session-123" } },
})
expect(body?.prompt_cache_key).toBe("session-123")
})
+3 -3
View File
@@ -255,19 +255,19 @@ it.effect("generates from fresh settled Session context without durable mutation
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID,
assistantMessageID: activeAssistant,
callID: "active-call",
id: "active-call",
name: "echo",
})
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
assistantMessageID: activeAssistant,
callID: "active-call",
id: "active-call",
text: "{}",
})
yield* bus.publish(SessionEvent.Tool.Called, {
sessionID,
assistantMessageID: activeAssistant,
callID: "active-call",
id: "active-call",
input: {},
executed: false,
})
@@ -237,7 +237,7 @@ test("success event data can carry provider-executed result state", () => {
const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
sessionID,
assistantMessageID: SessionMessage.ID.create(),
callID: "call-old",
id: "call-old",
content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }],
executed: true,
resultState: {
@@ -339,7 +339,7 @@ describe("Tool", () => {
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
})
expect(contexts).toEqual([
{ sessionID, ...identity, callID: Tool.CallID.make("call-context"), progress: expect.any(Function) },
{ sessionID, ...identity, id: Tool.CallID.make("call-context"), progress: expect.any(Function) },
])
}),
)
+51 -21
View File
@@ -513,6 +513,16 @@ const providerUnavailable = () =>
reason: new TransportReason({ message: "Provider unavailable" }),
})
const incompleteStream = () =>
new AIError({
module: "test",
method: "stream",
reason: new InvalidProviderOutputReason({
classification: "incomplete-stream",
message: "The provider response ended unexpectedly.",
}),
})
const invalidRequest = () =>
new AIError({
module: "test",
@@ -935,7 +945,7 @@ describe("SessionRunnerLLM", () => {
yield* TestLLM.push(TestLLM.tool("call-location", "location_context", { query: "hello" }), [])
const bus = yield* Bus.Service
const progressFiber = yield* bus.subscribe(SessionEvent.Tool.Progress).pipe(
Stream.filter((event) => event.data.sessionID === sessionID && event.data.callID === "call-location"),
Stream.filter((event) => event.data.sessionID === sessionID && event.data.id === "call-location"),
Stream.take(1),
Stream.runCollect,
Effect.forkScoped({ startImmediately: true }),
@@ -949,7 +959,7 @@ describe("SessionRunnerLLM", () => {
sessionID,
agent: Agent.ID.make("build"),
messageID: expect.stringMatching(/^msg_/),
callID: Tool.CallID.make("call-location"),
id: Tool.CallID.make("call-location"),
progress: expect.any(Function),
},
])
@@ -2382,7 +2392,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2)
expect(messageRoles(requests[1])).toEqual(["user", "assistant", "tool"])
expect(authorizations).toMatchObject([{ sessionID, callID: "call-echo" }])
expect(authorizations).toMatchObject([{ sessionID, id: "call-echo" }])
expect(executions).toEqual(["hello"])
const context = yield* session.context(sessionID)
expect(context).toMatchObject([
@@ -2994,19 +3004,19 @@ describe("SessionRunnerLLM", () => {
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID,
assistantMessageID,
callID: "call-interrupted",
id: "call-interrupted",
name: "echo",
})
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
assistantMessageID,
callID: "call-interrupted",
id: "call-interrupted",
text: '{"text":"stale"}',
})
yield* bus.publish(SessionEvent.Tool.Called, {
sessionID,
assistantMessageID,
callID: "call-interrupted",
id: "call-interrupted",
input: { text: "stale" },
executed: false,
})
@@ -3051,19 +3061,19 @@ describe("SessionRunnerLLM", () => {
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID,
assistantMessageID,
callID: "call-hosted-interrupted",
id: "call-hosted-interrupted",
name: "web_search",
})
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
assistantMessageID,
callID: "call-hosted-interrupted",
id: "call-hosted-interrupted",
text: '{"query":"stale"}',
})
yield* bus.publish(SessionEvent.Tool.Called, {
sessionID,
assistantMessageID,
callID: "call-hosted-interrupted",
id: "call-hosted-interrupted",
input: { query: "stale" },
executed: true,
state: { itemId: "call-hosted-interrupted" },
@@ -3102,7 +3112,7 @@ describe("SessionRunnerLLM", () => {
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID,
assistantMessageID,
callID: "call-pending-interrupted",
id: "call-pending-interrupted",
name: "echo",
})
requests.length = 0
@@ -3949,6 +3959,26 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("retries an incomplete stream before output", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Retry incomplete stream")
yield* TestLLM.push(Stream.fail(incompleteStream()))
yield* TestLLM.push(TestLLM.text("Recovered", "incomplete-stream-success"))
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
yield* TestClock.adjust("2 seconds")
yield* Fiber.join(run)
expect(requests).toHaveLength(2)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user" },
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
])
}),
)
it.effect("uses a larger provider retry-after delay", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -3969,7 +3999,7 @@ describe("SessionRunnerLLM", () => {
it.effect("does not retry eligible failures after observable output", () =>
Effect.gen(function* () {
const session = yield* setup
const failure = rateLimited()
const failure = incompleteStream()
yield* TestLLM.push(
TestLLM.failAfter(
failure,
@@ -3987,7 +4017,7 @@ describe("SessionRunnerLLM", () => {
{
type: "assistant",
finish: "error",
error: { type: "provider.rate-limit" },
error: { type: "provider.invalid-output" },
content: [{ type: "text", text: "Partial" }],
},
])
@@ -4120,7 +4150,7 @@ describe("SessionRunnerLLM", () => {
{
type: "session.tool.failed.2",
data: {
callID: "call-malformed",
id: "call-malformed",
error: { type: "provider.invalid-output", message: "Invalid JSON input for tool call echo" },
},
},
@@ -4220,7 +4250,7 @@ describe("SessionRunnerLLM", () => {
.all()
.pipe(Effect.orDie)
expect(durable.find((event) => event.type === "session.tool.input.ended.1")?.data).toMatchObject({
callID: "call-malformed",
id: "call-malformed",
text: raw,
})
}),
@@ -4616,13 +4646,13 @@ describe("SessionRunnerLLM", () => {
const assistant = requireAssistant(yield* session.context(sessionID))
const bus = yield* recordedStepSettlementEvents(sessionID, assistant.id)
expect(bus.map((event) => ({ type: event.type, callID: event.data.callID }))).toEqual([
{ type: "session.step.started.1", callID: undefined },
{ type: "session.tool.called.1", callID: "call-local-raw-failure" },
{ type: "session.tool.called.1", callID: "call-hosted-raw-failure-pair" },
{ type: "session.tool.failed.2", callID: "call-local-raw-failure" },
{ type: "session.tool.failed.2", callID: "call-hosted-raw-failure-pair" },
{ type: "session.step.failed.1", callID: undefined },
expect(bus.map((event) => ({ type: event.type, id: event.data.id }))).toEqual([
{ type: "session.step.started.1", id: undefined },
{ type: "session.tool.called.1", id: "call-local-raw-failure" },
{ type: "session.tool.called.1", id: "call-hosted-raw-failure-pair" },
{ type: "session.tool.failed.2", id: "call-local-raw-failure" },
{ type: "session.tool.failed.2", id: "call-hosted-raw-failure-pair" },
{ type: "session.step.failed.1", id: undefined },
])
expect(
bus.filter((event) => event.type.startsWith("session.step.") && event.type !== "session.step.started.1"),
@@ -65,18 +65,18 @@ describe("Tool.Metadata", () => {
if (!row) return yield* Effect.die("Missing projected assistant")
return Schema.decodeUnknownSync(SessionMessage.Assistant)({ ...row.data, id: row.id, type: row.type })
})
const start = (callID: string) =>
const start = (id: string) =>
Effect.gen(function* () {
yield* service.publish(SessionEvent.Tool.Input.Started, {
sessionID,
assistantMessageID,
callID,
id,
name: "bash",
})
yield* service.publish(SessionEvent.Tool.Called, {
sessionID,
assistantMessageID,
callID,
id,
input: { command: "pwd" },
executed: false,
})
@@ -90,7 +90,7 @@ describe("Tool.Metadata", () => {
const progress = yield* service.publish(SessionEvent.Tool.Progress, {
sessionID,
assistantMessageID,
callID: "call-success",
id: "call-success",
metadata: { phase: "checkpoint" },
})
expect((yield* readAssistant).content[0]).toMatchObject({
@@ -100,7 +100,7 @@ describe("Tool.Metadata", () => {
const success = yield* service.publish(SessionEvent.Tool.Success, {
sessionID,
assistantMessageID,
callID: "call-success",
id: "call-success",
metadata: { phase: "done" },
content: content("complete"),
executed: false,
@@ -113,13 +113,13 @@ describe("Tool.Metadata", () => {
yield* service.publish(SessionEvent.Tool.Progress, {
sessionID,
assistantMessageID,
callID: "call-failed",
id: "call-failed",
metadata: { phase: "checkpoint" },
})
const failed = yield* service.publish(SessionEvent.Tool.Failed, {
sessionID,
assistantMessageID,
callID: "call-failed",
id: "call-failed",
error: { type: "unknown", message: "boom" },
metadata: { phase: "checkpoint" },
content: content("before failure"),
+1 -1
View File
@@ -12,7 +12,7 @@ const context = {
sessionID: Session.ID.make("ses_execute"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_execute"),
callID: Tool.CallID.make("call_execute"),
id: Tool.CallID.make("call_execute"),
progress: () => Effect.void,
}
+2 -2
View File
@@ -166,7 +166,7 @@ describe("QuestionTool", () => {
expect(capturedInput()).toEqual({
sessionID,
title: "Questions",
metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, callID: "call-question" } },
metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, id: "call-question" } },
fields: [
{
key: "q0",
@@ -212,7 +212,7 @@ describe("QuestionTool", () => {
expect(capturedInput()).toEqual({
sessionID,
title: "Questions",
metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, callID: "call-question" } },
metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, id: "call-question" } },
fields: [
{
key: "q0",
+2 -2
View File
@@ -17,7 +17,7 @@ export interface ToolHooks {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly callID: Tool.CallID
readonly id: Tool.CallID
input: unknown
}
readonly "execute.after": {
@@ -25,7 +25,7 @@ export interface ToolHooks {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly callID: Tool.CallID
readonly id: Tool.CallID
readonly input: unknown
} & (
| {
+2 -2
View File
@@ -34,7 +34,7 @@ interface ToolHooks {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly callID: Tool.CallID
readonly id: Tool.CallID
input: unknown
}
readonly "execute.after": {
@@ -42,7 +42,7 @@ interface ToolHooks {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly callID: Tool.CallID
readonly id: Tool.CallID
readonly input: unknown
} & (
| {
+1 -1
View File
@@ -17,7 +17,7 @@ export const Source = Schema.Union([
Schema.Struct({
type: Schema.Literal("tool"),
messageID: Schema.String,
callID: Schema.String,
id: Schema.String,
}),
]).annotate({ identifier: "Permission.Source" })
export type Source = typeof Source.Type
+1 -1
View File
@@ -45,7 +45,7 @@ export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
export const Tool = Schema.Struct({
messageID: Schema.String,
callID: Schema.String,
id: Schema.String,
}).annotate({ identifier: "Question.Tool" })
export interface Tool extends Schema.Schema.Type<typeof Tool> {}
+1 -1
View File
@@ -365,7 +365,7 @@ export namespace Tool {
const ToolBase = {
...Base,
assistantMessageID: SessionMessage.ID,
callID: Schema.String,
id: Schema.String,
}
export namespace Input {
+1 -1
View File
@@ -15,7 +15,7 @@ export interface Context {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly callID: CallID
readonly id: CallID
readonly progress: (update: Metadata) => Effect.Effect<void>
}
+1 -1
View File
@@ -177,7 +177,7 @@ describe("public event manifest", () => {
const tool = SessionEvent.Tool.Called.data.make({
sessionID,
assistantMessageID,
callID: "call_test",
id: "call_test",
input: {},
executed: true,
state: { itemId: "item_test" },
@@ -477,7 +477,7 @@ const makeToolDriver = Effect.fn("SimulatedProvider.makeToolDriver")(function* (
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
callID: context.callID,
id: context.id,
},
}
const pending: PendingToolInvocation = {
+1 -1
View File
@@ -503,7 +503,7 @@ export namespace Backend {
sessionID: Schema.String,
agent: Schema.String,
messageID: Schema.String,
callID: Schema.String,
id: Schema.String,
}),
})
export interface ToolInvocation extends Schema.Schema.Type<typeof ToolInvocation> {}
@@ -279,7 +279,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
expect.objectContaining({ name: "lookup", description: "Look up a value" }),
)
const progress: Tool.Metadata[] = []
const executeCall = (callID: string, query: string) =>
const executeCall = (id: string, query: string) =>
toolSet.execute({
sessionID: Session.ID.make("ses_simulated_tools"),
agent: Agent.ID.make("build"),
@@ -287,7 +287,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
progress: (update) => Effect.sync(() => progress.push(update)),
call: {
type: "tool-call",
id: callID,
id: id,
name: "lookup",
input: { query },
},
@@ -302,7 +302,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
sessionID: "ses_simulated_tools",
agent: "build",
messageID: "msg_simulated_tools",
callID: "call_success",
id: "call_success",
},
})
const successID = requireString(requireRecord(successInvocation.params).id)
@@ -420,25 +420,25 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
invocations.map((invocation) => {
const params = requireRecord(invocation.params)
const context = requireRecord(params.context)
return [requireString(context.callID), requireString(params.id)]
return [requireString(context.id), requireString(params.id)]
}),
)
for (const [id, callID, value] of [
for (const [requestID, toolID, value] of [
[5, "call_second", "second result"],
[6, "call_first", "first result"],
] as const) {
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id,
id: requestID,
method: "tool.finish",
params: {
id: byCall.get(callID),
id: byCall.get(toolID),
output: { structured: value, content: [{ type: "text", text: value }] },
},
}),
)
expect(yield* Queue.take(messages)).toMatchObject({ id, result: { ok: true } })
expect(yield* Queue.take(messages)).toMatchObject({ id: requestID, result: { ok: true } })
}
expect(yield* Fiber.join(concurrent[0])).toMatchObject({
output: "first result",
+2 -2
View File
@@ -799,8 +799,8 @@ function App(props: { pair?: DialogPairCredentials }) {
title: "MCP servers",
category: "Agent",
slash: { name: "mcps" },
run: (server?: string) => {
dialog.replace(() => <DialogMcp server={server} />)
run: () => {
dialog.replace(() => <DialogMcp />)
},
},
{
+1 -12
View File
@@ -28,7 +28,7 @@ function Status(props: { enabled: boolean; loading: boolean }) {
return <span style={{ fg: theme.text.subdued }}> Disabled</span>
}
export function DialogMcp(props: { server?: string }) {
export function DialogMcp() {
const data = useData()
const dialog = useDialog()
const client = useClient()
@@ -37,7 +37,6 @@ export function DialogMcp(props: { server?: string }) {
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<McpServer>()
const [loading, setLoading] = createSignal<string | null>(null)
const [initial, setInitial] = createSignal(props.server)
const servers = createMemo(() =>
pipe(
@@ -46,16 +45,6 @@ export function DialogMcp(props: { server?: string }) {
),
)
createEffect(() => {
const name = initial()
if (!name) return
const server = servers().find((entry) => entry.name === name)
if (!server) return
setInitial()
setFocused(name)
if (statusError(server.status)) setDetail(server)
})
createEffect(() => {
if (focused()) return
const first = servers()[0]
+8 -8
View File
@@ -88,10 +88,10 @@ export const Definitions = {
session_new: keybind("<leader>n", "Create a new session"),
session_list: keybind("<leader>l", "List all sessions"),
open_menu: keybind("ctrl+o", "Open recent sessions and projects"),
session_tab_next: keybind("ctrl+tab,<leader>right,alt+shift+]", "Switch to next open tab"),
session_tab_previous: keybind("ctrl+shift+tab,<leader>left,alt+shift+[", "Switch to previous open tab"),
session_tab_next_unread: keybind("<leader>down", "Switch to next unread tab"),
session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread tab"),
session_tab_next: keybind("ctrl+tab,alt+down", "Switch to next open tab"),
session_tab_previous: keybind("ctrl+shift+tab,alt+up", "Switch to previous open tab"),
session_tab_next_unread: keybind("alt+shift+down", "Switch to next unread tab"),
session_tab_previous_unread: keybind("alt+shift+up", "Switch to previous unread tab"),
session_tab_close: keybind("<leader>w", "Close current tab"),
session_tab_reopen: keybind("ctrl+shift+t", "Reopen last closed tab"),
session_timeline: keybind("<leader>g", "Show session timeline"),
@@ -150,10 +150,10 @@ export const Definitions = {
messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"),
messages_first: keybind("ctrl+g,home,alt+home", "Navigate to first message"),
messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"),
messages_next: keybind("alt+down", "Navigate to next message"),
messages_previous: keybind("alt+up", "Navigate to previous message"),
messages_next_user: keybind("alt+shift+down", "Navigate to next user message"),
messages_previous_user: keybind("alt+shift+up", "Navigate to previous user message"),
messages_next: keybind("none", "Navigate to next message"),
messages_previous: keybind("none", "Navigate to previous message"),
messages_next_user: keybind("none", "Navigate to next user message"),
messages_previous_user: keybind("none", "Navigate to previous user message"),
messages_last_user: keybind("alt+end", "Navigate to last user message"),
messages_copy: keybind("<leader>y", "Copy message"),
messages_undo: keybind("<leader>u", "Undo message"),
+9 -9
View File
@@ -208,10 +208,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const item = messages.findLast((item) => item.type === "compaction" && item.status === "running")
return item?.type === "compaction" ? item : undefined
},
latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
latestTool(assistant: SessionMessageAssistant | undefined, id?: string) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantTool =>
item.type === "tool" && (callID === undefined || item.id === callID),
item.type === "tool" && (id === undefined || item.id === id),
)
},
latestText(assistant: SessionMessageAssistant | undefined) {
@@ -592,7 +592,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "tool",
id: event.data.callID,
id: event.data.id,
name: event.data.name,
time: { created: event.created },
state: { status: "streaming", input: "" },
@@ -603,7 +603,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.callID,
event.data.id,
)
if (match?.state.status === "streaming") match.state.input += event.data.delta
})
@@ -612,7 +612,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.callID,
event.data.id,
)
if (match?.state.status === "streaming") match.state.input = event.data.text
})
@@ -621,7 +621,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.callID,
event.data.id,
)
if (!match) return
match.time.ran = event.created
@@ -634,7 +634,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.callID,
event.data.id,
)
if (match?.state.status !== "running") return
match.state.metadata = event.data.metadata
@@ -644,7 +644,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.callID,
event.data.id,
)
if (match?.state.status !== "running") return
match.state = {
@@ -662,7 +662,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.callID,
event.data.id,
)
if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return
match.state = {
+10 -2
View File
@@ -82,11 +82,19 @@ export function moveSessionTab(tabs: SessionTab[], sessionID: string, index: num
return next
}
export function cycleSessionTab(tabs: readonly SessionTab[], active: string | undefined, direction: 1 | -1) {
export function cycleSessionTab(
tabs: readonly SessionTab[],
active: string | undefined,
direction: 1 | -1,
matches: (tab: SessionTab) => boolean = () => true,
) {
if (tabs.length === 0) return
const index = tabs.findIndex((tab) => tab.sessionID === active)
const start = index === -1 ? (direction === 1 ? -1 : 0) : index
return tabs[(start + direction + tabs.length) % tabs.length]
return Array.from(
{ length: tabs.length },
(_, offset) => tabs[(start + direction * (offset + 1) + tabs.length * 2) % tabs.length],
).find(matches)
}
// In-memory navigation history is bounded so a long-lived TUI does not accumulate one entry per
+2 -4
View File
@@ -298,10 +298,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
},
cycleUnread(direction: 1 | -1) {
if (!enabled()) return
const tab = cycleSessionTab(
state().tabs.filter((tab) => state().unread[tab.sessionID] || status(tab.sessionID).attention),
current(),
direction,
const tab = cycleSessionTab(state().tabs, current(), direction, (tab) =>
Boolean(state().unread[tab.sessionID] || status(tab.sessionID).attention),
)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
@@ -1,7 +1,7 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
export function McpSidebar(props: { context: Plugin.Context; sessionID: string }) {
function View(props: { context: Plugin.Context; sessionID: string }) {
const [open, setOpen] = createSignal(true)
const theme = props.context.theme
const session = createMemo(() => props.context.data.session.get(props.sessionID))
@@ -46,14 +46,7 @@ export function McpSidebar(props: { context: Plugin.Context; sessionID: string }
<Show when={list().length <= 2 || open()}>
<For each={list()}>
{(item) => (
<box
flexDirection="row"
gap={1}
onMouseUp={() => {
if (item.status.status !== "failed" && item.status.status !== "needs_client_registration") return
props.context.keymap.dispatch("mcp.list", item.name)
}}
>
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
@@ -67,7 +60,9 @@ export function McpSidebar(props: { context: Plugin.Context; sessionID: string }
<span style={{ fg: theme.text.subdued }}>
<Switch fallback={item.status.status}>
<Match when={item.status.status === "connected"}>Connected</Match>
<Match when={item.status.status === "failed"}>Failed</Match>
<Match when={item.status.status === "failed"}>
<i>{item.status.status === "failed" ? item.status.error : undefined}</i>
</Match>
<Match when={item.status.status === "disabled"}>Disabled</Match>
<Match when={item.status.status === "needs_auth"}>Needs auth</Match>
<Match when={item.status.status === "needs_client_registration"}>Needs client ID</Match>
@@ -86,6 +81,6 @@ export function McpSidebar(props: { context: Plugin.Context; sessionID: string }
export default Plugin.define({
id: "internal:sidebar-mcp",
setup(context) {
context.ui.slot("sidebar.content", (props) => <McpSidebar context={context} sessionID={props.sessionID} />)
context.ui.slot("sidebar.content", (props) => <View context={context} sessionID={props.sessionID} />)
},
})
+2 -2
View File
@@ -373,7 +373,7 @@ function askPermission(state: State, item: Permit): void {
resources: item.patterns,
metadata: item.metadata ?? {},
save: item.always,
source: { type: "tool", messageID: item.ref.msg, callID: item.ref.call },
source: { type: "tool", messageID: item.ref.msg, id: item.ref.call },
tool,
},
})
@@ -805,7 +805,7 @@ function emitForm(state: State, kind: FormKind = "question"): void {
title: form.title,
metadata:
kind === "question"
? { kind: "question", tool: { messageID: ref.msg, callID: ref.call } }
? { kind: "question", tool: { messageID: ref.msg, id: ref.call } }
: { kind: "mcp", message: `Synthetic ${kind} MCP elicitation` },
fields: form.fields,
}
+17 -17
View File
@@ -164,13 +164,13 @@ function text(value: unknown): string | undefined {
return next || undefined
}
function sourceKey(messageID: string, callID: string) {
return `${messageID}\u0000${callID}`
function sourceKey(messageID: string, id: string) {
return `${messageID}\u0000${id}`
}
function permissionTool(request: PermissionRequest, tools: Map<string, SessionMessageAssistantTool>) {
if (request.source?.type !== "tool") return request
const tool = tools.get(sourceKey(request.source.messageID, request.source.callID))
const tool = tools.get(sourceKey(request.source.messageID, request.source.id))
return tool ? { ...request, tool } : request
}
@@ -444,7 +444,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
...new Set(
permissions.flatMap((request) => {
if (request.source?.type !== "tool") return []
const key = sourceKey(request.source.messageID, request.source.callID)
const key = sourceKey(request.source.messageID, request.source.id)
return child.toolSources.has(key) ? [] : [request.source.messageID]
}),
),
@@ -475,7 +475,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
permissions.some(
(request) =>
request.source?.type === "tool" &&
!child.toolSources.has(sourceKey(request.source.messageID, request.source.callID)),
!child.toolSources.has(sourceKey(request.source.messageID, request.source.id)),
)
)
throw new Error("Permission source tool is unavailable")
@@ -737,12 +737,12 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
return
}
if (event.type === "session.tool.input.started") {
if (child.finishedTools.has(sourceKey(event.data.assistantMessageID, event.data.callID))) return
if (child.finishedTools.has(sourceKey(event.data.assistantMessageID, event.data.id))) return
childTool(
child,
{
type: "tool",
id: event.data.callID,
id: event.data.id,
name: event.data.name,
state: { status: "streaming", input: "" },
time: { created: event.created },
@@ -752,7 +752,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
return
}
if (event.type === "session.tool.input.delta" || event.type === "session.tool.input.ended") {
const current = child.tools.get(sourceKey(event.data.assistantMessageID, event.data.callID))
const current = child.tools.get(sourceKey(event.data.assistantMessageID, event.data.id))
if (!current || current.part.state.status !== "streaming") return
childTool(
child,
@@ -769,14 +769,14 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
return
}
if (event.type === "session.tool.called") {
const key = sourceKey(event.data.assistantMessageID, event.data.callID)
const key = sourceKey(event.data.assistantMessageID, event.data.id)
if (child.finishedTools.has(key)) return
const current = child.tools.get(key)
childTool(
child,
{
type: "tool",
id: event.data.callID,
id: event.data.id,
name: current?.part.name ?? "tool",
executed: event.data.executed,
providerState: event.data.state,
@@ -790,7 +790,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
return
}
if (event.type === "session.tool.progress") {
const key = sourceKey(event.data.assistantMessageID, event.data.callID)
const key = sourceKey(event.data.assistantMessageID, event.data.id)
if (child.finishedTools.has(key)) return
const current = child.tools.get(key)
const part = current?.part
@@ -798,7 +798,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
child,
{
type: "tool",
id: event.data.callID,
id: event.data.id,
name: part?.name ?? "tool",
executed: part?.executed,
providerState: part?.providerState,
@@ -819,7 +819,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
return
}
if (event.type === "session.tool.success" || event.type === "session.tool.failed") {
const key = sourceKey(event.data.assistantMessageID, event.data.callID)
const key = sourceKey(event.data.assistantMessageID, event.data.id)
if (child.finishedTools.has(key)) return
const current = child.tools.get(key)
const part = current?.part
@@ -828,7 +828,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
child,
{
type: "tool",
id: event.data.callID,
id: event.data.id,
name: part?.name ?? "tool",
executed: event.data.executed,
providerState: part?.providerState,
@@ -947,11 +947,11 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
if (!active(signal)) return
if (event.type === "session.tool.input.started") {
if (canonicalToolName(event.data.name) === "subagent")
pendingCalls.set(sourceKey(event.data.assistantMessageID, event.data.callID), {})
pendingCalls.set(sourceKey(event.data.assistantMessageID, event.data.id), {})
return
}
if (event.type === "session.tool.called") {
const key = sourceKey(event.data.assistantMessageID, event.data.callID)
const key = sourceKey(event.data.assistantMessageID, event.data.id)
if (pendingCalls.has(key)) pendingCalls.set(key, event.data.input)
return
}
@@ -961,7 +961,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
event.type !== "session.tool.failed"
)
return
const key = sourceKey(event.data.assistantMessageID, event.data.callID)
const key = sourceKey(event.data.assistantMessageID, event.data.id)
const pending = pendingCalls.get(key)
if (event.type !== "session.tool.progress") pendingCalls.delete(key)
const found = childSessionID(record(event.data.metadata))
+26 -26
View File
@@ -91,12 +91,12 @@ type Wait = {
}
// One active session.shell call. The HTTP response is the completion signal;
// callID correlates the live shell events once shell.started is observed, and
// id correlates the live shell events once shell.started is observed, and
// abort cancels the blocking request when the user interrupts the turn.
type ShellWait = {
eventID: string
messageID: string
callID?: string
id?: string
resolve: () => void
abort: () => void
}
@@ -291,27 +291,27 @@ function streamPartKey(messageID: string, partID: string) {
return `${messageID}\u0000${partID}`
}
function permissionSourceKey(messageID: string, callID: string) {
return streamPartKey(messageID, callID)
function permissionSourceKey(messageID: string, id: string) {
return streamPartKey(messageID, id)
}
function permissionTool(request: PermissionRequest, tools: Map<string, SessionMessageAssistantTool>) {
if (request.source?.type !== "tool") return request
const tool = tools.get(permissionSourceKey(request.source.messageID, request.source.callID))
const tool = tools.get(permissionSourceKey(request.source.messageID, request.source.id))
return tool ? { ...request, tool } : request
}
// Direct shell calls use one "start" commit rendering `$ command` and one "progress"
// commit rendering the merged output (see toolEntryBody in tool.ts).
function shellCommit(
callID: string,
id: string,
command: string,
next: Pick<StreamCommit, "text" | "phase" | "toolState" | "toolError">,
): StreamCommit {
return {
kind: "tool",
source: "tool",
partID: `shell:${callID}`,
partID: `shell:${id}`,
tool: "shell",
shell: { command },
...next,
@@ -319,7 +319,7 @@ function shellCommit(
}
function shellTerminal(
callID: string,
id: string,
command: string,
shell: { status: string; exit?: number | string },
output: { output: string; cursor: number; size: number; truncated: boolean },
@@ -332,10 +332,10 @@ function shellTerminal(
: shell.status === "exited"
? `Shell exited with code ${shell.exit ?? "unknown"}`
: `Shell ${shell.status}`
if (!error) return [shellCommit(callID, command, { text, phase: "progress", toolState: "completed" })]
if (!error) return [shellCommit(id, command, { text, phase: "progress", toolState: "completed" })]
return [
...(text ? [shellCommit(callID, command, { text, phase: "progress", toolState: "running" })] : []),
shellCommit(callID, command, { text: error, phase: "final", toolState: "error", toolError: error }),
...(text ? [shellCommit(id, command, { text, phase: "progress", toolState: "running" })] : []),
shellCommit(id, command, { text: error, phase: "final", toolState: "error", toolError: error }),
]
}
@@ -570,7 +570,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
const sourcePending = (key: string) =>
state.permissions.some(
(request) =>
request.source?.type === "tool" && permissionSourceKey(request.source.messageID, request.source.callID) === key,
request.source?.type === "tool" && permissionSourceKey(request.source.messageID, request.source.id) === key,
)
const pruneToolSources = () => {
@@ -647,7 +647,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
if (message.type === "shell") {
state.shellCommands.set(message.shellID, message.command)
if (state.shellWait?.messageID === message.id) state.shellWait.callID = message.shellID
if (state.shellWait?.messageID === message.id) state.shellWait.id = message.shellID
const completed = message.time.completed !== undefined
if (!render) {
// Suppressed history: mark settled shells rendered so live redelivery
@@ -673,7 +673,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
state.shellEnded.add(message.shellID)
write(shellTerminal(message.shellID, message.command, message, message.output))
}
if (completed && state.shellWait?.callID === message.shellID) state.shellWait.resolve()
if (completed && state.shellWait?.id === message.shellID) state.shellWait.resolve()
return
}
if (message.type === "compaction") {
@@ -776,14 +776,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
) => {
const pending = new Set(
permissions.flatMap((request) =>
request.source?.type === "tool" ? [permissionSourceKey(request.source.messageID, request.source.callID)] : [],
request.source?.type === "tool" ? [permissionSourceKey(request.source.messageID, request.source.id)] : [],
),
)
const messageIDs = [
...new Set(
permissions.flatMap((request) => {
if (request.source?.type !== "tool") return []
const key = permissionSourceKey(request.source.messageID, request.source.callID)
const key = permissionSourceKey(request.source.messageID, request.source.id)
return state.toolSources.has(key) ? [] : [request.source.messageID]
}),
),
@@ -992,7 +992,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (event.type === "session.shell.started") {
state.shellCommands.set(event.data.shell.id, event.data.shell.command)
const wait = state.shellWait
if (wait?.eventID === event.id) wait.callID = event.data.shell.id
if (wait?.eventID === event.id) wait.id = event.data.shell.id
if (state.shellStarted.has(event.data.shell.id)) return
state.shellStarted.add(event.data.shell.id)
write(
@@ -1025,7 +1025,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
commits.push(...shellTerminal(event.data.shell.id, command, event.data.shell, event.data.output))
}
const wait = state.shellWait
const owned = wait?.callID === event.data.shell.id
const owned = wait?.id === event.data.shell.id
write(commits, owned || state.wait || state.shellWait ? undefined : { phase: "idle", status: "" })
if (owned) wait.resolve()
return
@@ -1109,7 +1109,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (event.type === "session.tool.input.started") {
renderTool(event.data.assistantMessageID, {
type: "tool",
id: event.data.callID,
id: event.data.id,
name: event.data.name,
state: { status: "streaming", input: "" },
time: { created: event.created },
@@ -1117,7 +1117,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (event.type === "session.tool.input.delta" || event.type === "session.tool.input.ended") {
const current = state.tools.get(streamPartKey(event.data.assistantMessageID, event.data.callID))
const current = state.tools.get(streamPartKey(event.data.assistantMessageID, event.data.id))
if (!current || current.part.state.status !== "streaming") return
renderTool(event.data.assistantMessageID, {
...current.part,
@@ -1130,12 +1130,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (event.type === "session.tool.called") {
const key = streamPartKey(event.data.assistantMessageID, event.data.callID)
const key = streamPartKey(event.data.assistantMessageID, event.data.id)
if (state.finishedTools.has(key)) return
const current = state.tools.get(key)
const item: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
id: event.data.id,
name: current?.part.name ?? "tool",
executed: event.data.executed,
providerState: event.data.state,
@@ -1146,13 +1146,13 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (event.type === "session.tool.progress") {
const key = streamPartKey(event.data.assistantMessageID, event.data.callID)
const key = streamPartKey(event.data.assistantMessageID, event.data.id)
if (state.finishedTools.has(key)) return
const current = state.tools.get(key)
const part = current?.part
renderTool(event.data.assistantMessageID, {
type: "tool",
id: event.data.callID,
id: event.data.id,
name: part?.name ?? "tool",
executed: part?.executed,
providerState: part?.providerState,
@@ -1166,12 +1166,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (event.type === "session.tool.success" || event.type === "session.tool.failed") {
const current = state.tools.get(streamPartKey(event.data.assistantMessageID, event.data.callID))
const current = state.tools.get(streamPartKey(event.data.assistantMessageID, event.data.id))
const part = current?.part
const failed = event.type === "session.tool.failed"
const item: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
id: event.data.id,
name: part?.name ?? "tool",
executed: event.data.executed,
providerState: part?.providerState,
+2 -2
View File
@@ -244,11 +244,11 @@ type MiniToolState =
// Retained only for the noninteractive run JSON/V1 compatibility boundary.
// Interactive Mini commits carry SessionMessageAssistantTool directly.
export type MiniToolPart = {
id: string
partID: string
sessionID: string
messageID: string
type?: "tool"
callID: string
id: string
tool: string
state: MiniToolState
}
+1 -1
View File
@@ -2227,7 +2227,7 @@ function useToolPermission(part: () => SessionMessageAssistantTool | undefined)
return createMemo(() => {
if (local.permission.mode === "auto") return false
const request = data.session.permission.list(ctx.sessionID)?.[0]
return request?.source?.type === "tool" && request.source.callID === part()?.id
return request?.source?.type === "tool" && request.source.id === part()?.id
})
}
@@ -123,7 +123,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
if (!tool) return { input: undefined, metadata: undefined }
const message = data.session.message.get(props.request.sessionID, tool.messageID)
if (message?.type !== "assistant") return { input: undefined, metadata: undefined }
const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID)
const part = message.content.find((part) => part.type === "tool" && part.id === tool.id)
if (part?.type === "tool" && part.state.status !== "streaming") {
return { input: part.state.input, metadata: part.state.metadata }
}
+2 -2
View File
@@ -68,7 +68,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
function pendingPermissions() {
return new Set(
(data.session.permission.list(sessionID()) ?? []).flatMap((request) =>
request.source?.type === "tool" ? [request.source.callID] : [],
request.source?.type === "tool" ? [request.source.id] : [],
),
)
}
@@ -255,7 +255,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
data.on("session.tool.input.started", (event) => {
if (event.data.sessionID === sessionID())
appendPart(
{ messageID: event.data.assistantMessageID, partID: event.data.callID },
{ messageID: event.data.assistantMessageID, partID: event.data.id },
{ type: "tool", name: event.data.name },
)
}),
+6 -6
View File
@@ -880,7 +880,7 @@ test("completes exploration when a queued prompt is promoted", async () => {
data: {
sessionID,
assistantMessageID: "message-assistant",
callID: "call-read",
id: "call-read",
name: "read",
},
})
@@ -951,7 +951,7 @@ test("classifies live tool rows independently of their call ID", async () => {
data: {
sessionID,
assistantMessageID: "message-assistant",
callID: "reasoning:0",
id: "reasoning:0",
name: "bash",
},
})
@@ -2485,7 +2485,7 @@ test("settles pending tools when a live failure arrives", async () => {
data: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1",
id: "call-1",
name: "bash",
},
})
@@ -2497,7 +2497,7 @@ test("settles pending tools when a live failure arrives", async () => {
data: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1",
id: "call-1",
input: {},
executed: false,
state: { call: true },
@@ -2510,7 +2510,7 @@ test("settles pending tools when a live failure arrives", async () => {
data: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1",
id: "call-1",
metadata: { sessionID: "session-child", status: "running" },
},
})
@@ -2533,7 +2533,7 @@ test("settles pending tools when a live failure arrives", async () => {
data: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1",
id: "call-1",
error: { type: "unknown", message: "aborted" },
executed: false,
resultState: { result: true },
+9 -11
View File
@@ -90,10 +90,10 @@ test("resolves message navigation defaults", () => {
const config = resolve({}, { terminalSuspend: true })
expect(config.keybinds.get("session.first")).toMatchObject([{ key: "ctrl+g,home,alt+home" }])
expect(config.keybinds.get("session.message.previous")).toMatchObject([{ key: "alt+up" }])
expect(config.keybinds.get("session.message.next")).toMatchObject([{ key: "alt+down" }])
expect(config.keybinds.get("session.message.user.previous")).toMatchObject([{ key: "alt+shift+up" }])
expect(config.keybinds.get("session.message.user.next")).toMatchObject([{ key: "alt+shift+down" }])
expect(config.keybinds.get("session.message.previous")).toEqual([])
expect(config.keybinds.get("session.message.next")).toEqual([])
expect(config.keybinds.get("session.message.user.previous")).toEqual([])
expect(config.keybinds.get("session.message.user.next")).toEqual([])
expect(config.keybinds.get("session.messages_last_user")).toMatchObject([{ key: "alt+end" }])
})
@@ -116,15 +116,13 @@ test("opens the subagent picker with down", () => {
expect(config.keybinds.get("session.child.first")).toMatchObject([{ key: "down" }])
})
test("navigates session tabs with leader arrows", () => {
test("navigates session tabs with option arrows", () => {
const config = resolve({}, { terminalSuspend: true })
expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,<leader>right,alt+shift+]" }])
expect(config.keybinds.get("session.tab.previous")).toMatchObject([
{ key: "ctrl+shift+tab,<leader>left,alt+shift+[" },
])
expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "<leader>down" }])
expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "<leader>up" }])
expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,alt+down" }])
expect(config.keybinds.get("session.tab.previous")).toMatchObject([{ key: "ctrl+shift+tab,alt+up" }])
expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "alt+shift+down" }])
expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "alt+shift+up" }])
})
test("preserves pinned session bindings alongside tab bindings", () => {
@@ -93,6 +93,17 @@ describe("session tabs", () => {
expect(cycleSessionTab(tabs, "b", 1)?.sessionID).toBe("a")
})
test("cycles to the nearest matching tab from an unmatched active tab", () => {
const tabs = ["a", "b", "c", "d", "e"].map((sessionID) => ({ sessionID }))
const unread = new Set(["a", "d"])
const matches = (tab: { sessionID: string }) => unread.has(tab.sessionID)
expect(cycleSessionTab(tabs, "c", 1, matches)?.sessionID).toBe("d")
expect(cycleSessionTab(tabs, "c", -1, matches)?.sessionID).toBe("a")
expect(cycleSessionTab(tabs, "e", 1, matches)?.sessionID).toBe("a")
expect(cycleSessionTab(tabs, "a", -1, matches)?.sessionID).toBe("d")
})
test("moves backward and forward through selection history", () => {
const tabs = ["a", "b", "c", "d"].map((sessionID) => ({ sessionID }))
const history = ["a", "b", "c", "d"].reduce(recordSessionTabHistory, { entries: [], index: -1 })
@@ -1,51 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { testRender } from "@opentui/solid"
import type { Context } from "@opencode-ai/plugin/tui/context"
import { McpSidebar } from "../../src/feature-plugins/sidebar/mcp"
function context() {
const color = RGBA.fromInts(200, 200, 200)
return {
theme: {
text: {
default: color,
subdued: color,
feedback: { success: { default: color }, error: { default: color }, warning: { default: color } },
},
},
data: {
session: { get: () => ({ location: { directory: "/workspace" } }) },
location: {
mcp: {
server: {
list: () => [
{
name: "broken",
status: { status: "failed", error: "<!DOCTYPE html><html><body>raw response</body></html>" },
},
],
},
},
},
},
} as unknown as Context
}
test("sidebar summarizes MCP failures without rendering error details", async () => {
const app = await testRender(() => <McpSidebar context={context()} sessionID="session" />, {
width: 42,
height: 8,
})
try {
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("broken Failed")
expect(frame).not.toContain("DOCTYPE")
expect(frame).not.toContain("raw response")
} finally {
app.renderer.destroy()
}
})
@@ -89,7 +89,7 @@ describe("run permission shared", () => {
permissionInfo(
req({
action: "shell",
source: { type: "tool", messageID: "msg-shell", callID: "call-shell" },
source: { type: "tool", messageID: "msg-shell", id: "call-shell" },
tool: canonicalToolPart(
"shell",
{
@@ -134,7 +134,7 @@ describe("run permission shared", () => {
req({
action: "websearch",
metadata: { provider: "parallel" },
source: { type: "tool", messageID: "msg-search", callID: "call-search" },
source: { type: "tool", messageID: "msg-search", id: "call-search" },
tool: canonicalToolPart(
"websearch",
{
@@ -157,7 +157,7 @@ describe("run permission shared", () => {
const request = req({
action: "edit",
resources: ["src/index.ts"],
source: { type: "tool", messageID: "msg-edit", callID: "call-edit" },
source: { type: "tool", messageID: "msg-edit", id: "call-edit" },
tool: canonicalToolPart(
"edit",
{
@@ -416,7 +416,7 @@ describe("V2 mini transport", () => {
sessionID: "ses_child",
action: "shell",
resources: ["git status --short"],
source: { type: "tool", messageID: "msg_child_source", callID: "call_child_source" },
source: { type: "tool", messageID: "msg_child_source", id: "call_child_source" },
}
const client = sdk({
streams: [events],
@@ -2032,7 +2032,7 @@ describe("V2 mini transport", () => {
created: index * 3 + 1,
type: "session.tool.input.started",
durable: durable("ses_1", index * 3),
data: { sessionID: "ses_1", assistantMessageID: messageID, callID: "call_repeated", name: "read" },
data: { sessionID: "ses_1", assistantMessageID: messageID, id: "call_repeated", name: "read" },
})
events.push({
id: `evt_repeated_called_${index}`,
@@ -2042,7 +2042,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: messageID,
callID: "call_repeated",
id: "call_repeated",
input: { path: `${index + 1}.txt` },
executed: true,
},
@@ -2055,7 +2055,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: messageID,
callID: "call_repeated",
id: "call_repeated",
metadata: {},
content: [{ type: "text", text: "" }],
executed: true,
@@ -2098,7 +2098,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_progress",
callID: "call_progress",
id: "call_progress",
name: "shell",
},
})
@@ -2110,7 +2110,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_progress",
callID: "call_progress",
id: "call_progress",
input: { command: "printf partial && false" },
executed: true,
},
@@ -2122,7 +2122,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_progress",
callID: "call_progress",
id: "call_progress",
metadata: { checkpoint: 1 },
},
})
@@ -2134,7 +2134,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_progress",
callID: "call_progress",
id: "call_progress",
error: { type: "unknown", message: "boom" },
metadata: { checkpoint: 1 },
content: [{ type: "text", text: "partial" }],
@@ -2933,7 +2933,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_subagent",
callID: "call_failed_subagent",
id: "call_failed_subagent",
name: "subagent",
},
})
@@ -2945,7 +2945,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_subagent",
callID: "call_failed_subagent",
id: "call_failed_subagent",
input: { agent: "explore", description: "Inspect failure", prompt: "inspect" },
executed: true,
},
@@ -2958,7 +2958,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_subagent",
callID: "call_failed_subagent",
id: "call_failed_subagent",
error: { type: "unknown", message: "subagent failed" },
metadata: { sessionID: "ses_child_failed", status: "running" },
executed: true,
@@ -2996,7 +2996,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_subagent",
callID: "call_subagent",
id: "call_subagent",
name: "subagent",
},
})
@@ -3008,7 +3008,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_subagent",
callID: "call_subagent",
id: "call_subagent",
input: { agent: "explore", description: "Inspect progress", prompt: "inspect" },
executed: true,
},
@@ -3020,7 +3020,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_subagent",
callID: "call_subagent",
id: "call_subagent",
metadata: { sessionID: "ses_child_progress", status: "running" },
},
})
@@ -3046,7 +3046,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_child_progress",
assistantMessageID: "msg_child_tool",
callID: "call_child_shell",
id: "call_child_shell",
name: "shell",
},
})
@@ -3058,7 +3058,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_child_progress",
assistantMessageID: "msg_child_tool",
callID: "call_child_shell",
id: "call_child_shell",
input: { command: "printf child && false" },
executed: true,
},
@@ -3070,7 +3070,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_child_progress",
assistantMessageID: "msg_child_tool",
callID: "call_child_shell",
id: "call_child_shell",
metadata: { checkpoint: "child" },
},
})
@@ -3083,7 +3083,7 @@ describe("V2 mini transport", () => {
sessionID: "ses_child_progress",
action: "shell",
resources: ["printf child && false"],
source: { type: "tool", messageID: "msg_child_tool", callID: "call_child_shell" },
source: { type: "tool", messageID: "msg_child_tool", id: "call_child_shell" },
},
})
events.push({
@@ -3094,7 +3094,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_child_progress",
assistantMessageID: "msg_child_tool",
callID: "call_child_shell",
id: "call_child_shell",
error: { type: "unknown", message: "child boom" },
metadata: { checkpoint: "child" },
content: [{ type: "text", text: "child partial" }],
@@ -3531,24 +3531,24 @@ describe("V2 mini transport", () => {
footer: ui.api,
})
const states = () => ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : []))
const inputStarted = (callID: string, name: string, seq: number) =>
const inputStarted = (id: string, name: string, seq: number) =>
events.push({
id: `evt_started_${callID}`,
id: `evt_started_${id}`,
created: seq,
type: "session.tool.input.started",
durable: durable("ses_child", seq),
data: { sessionID: "ses_child", assistantMessageID: "msg_tool_projected", callID, name },
data: { sessionID: "ses_child", assistantMessageID: "msg_tool_projected", id, name },
})
const called = (callID: string, input: Record<string, unknown>, seq: number) =>
const called = (id: string, input: Record<string, unknown>, seq: number) =>
events.push({
id: `evt_called_${callID}`,
id: `evt_called_${id}`,
created: seq,
type: "session.tool.called",
durable: durable("ses_child", seq),
data: {
sessionID: "ses_child",
assistantMessageID: "msg_tool_projected",
callID,
id,
input,
executed: true,
},
@@ -3567,7 +3567,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_child",
assistantMessageID: "msg_tool_projected",
callID: "call_terminal",
id: "call_terminal",
metadata: {},
content: [{ type: "text", text: "found" }],
executed: true,
@@ -3705,7 +3705,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_parent_a",
callID: "call_sub",
id: "call_sub",
name: "subagent",
},
})
@@ -3717,7 +3717,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_parent_a",
callID: "call_sub",
id: "call_sub",
input: { agent: "explore", description: "Find things", prompt: "go", background: true },
executed: true,
},
@@ -3730,7 +3730,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_parent_a",
callID: "call_sub",
id: "call_sub",
metadata: { sessionID: "ses_child", status: "running", output: "" },
content: [{ type: "text", text: "" }],
executed: true,

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