Compare commits

..

2 Commits

Author SHA1 Message Date
Aiden Cline e5aeea550c fix(ai): preserve request transforms 2026-08-03 16:56:35 -05:00
Aiden Cline 930b1dde3c feat(ai): add native HTTP middleware 2026-08-03 16:27:29 -05:00
135 changed files with 1307 additions and 2053 deletions
+33 -20
View File
@@ -88,7 +88,6 @@
"@tanstack/solid-query": "5.91.4", "@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:", "@tanstack/solid-virtual": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5", "@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
@@ -285,9 +284,13 @@
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.18.8", "version": "1.18.8",
"dependencies": { "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", "@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode-ai/console-core": "workspace:*", "@opencode-ai/console-core": "workspace:*",
"@opencode-ai/console-resource": "workspace:*", "@opencode-ai/console-resource": "workspace:*",
"ai": "catalog:",
"zod": "catalog:", "zod": "catalog:",
}, },
"devDependencies": { "devDependencies": {
@@ -295,6 +298,7 @@
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"openai": "5.11.0",
"typescript": "catalog:", "typescript": "catalog:",
}, },
}, },
@@ -357,15 +361,18 @@
"@ai-sdk/cohere": "3.0.27", "@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104", "@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.51", "@ai-sdk/mistral": "3.0.51",
"@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26", "@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23", "@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/togetherai": "2.0.41", "@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39", "@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.102",
"@aws-sdk/credential-providers": "3.1057.0", "@aws-sdk/credential-providers": "3.1057.0",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:", "@effect/sql-sqlite-bun": "catalog:",
@@ -379,6 +386,7 @@
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4", "@silvia-odwyer/photon-node": "0.3.4",
"@standard-schema/spec": "catalog:", "@standard-schema/spec": "catalog:",
@@ -903,6 +911,7 @@
"@opentui/solid": "catalog:", "@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"get-east-asian-width": "catalog:", "get-east-asian-width": "catalog:",
@@ -1029,6 +1038,7 @@
"@shikijs/transformers": "3.20.0", "@shikijs/transformers": "3.20.0",
"@solid-primitives/resize-observer": "2.1.5", "@solid-primitives/resize-observer": "2.1.5",
"@types/luxon": "catalog:", "@types/luxon": "catalog:",
"ai": "catalog:",
"astro": "5.7.13", "astro": "5.7.13",
"diff": "catalog:", "diff": "catalog:",
"js-base64": "3.7.7", "js-base64": "3.7.7",
@@ -1074,16 +1084,17 @@
"tree-sitter-bash", "tree-sitter-bash",
], ],
"patchedDependencies": { "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", "@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", "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", "@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", "@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", "@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", "@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", "@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", "@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": { "overrides": {
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
@@ -1209,9 +1220,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/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.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": ["@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-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/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/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=="], "@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=="],
@@ -4933,7 +4944,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=="], "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@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=="], "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=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
@@ -5951,8 +5962,12 @@
"@actions/http-client/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], "@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/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": ["@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=="], "@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=="],
@@ -5967,8 +5982,6 @@
"@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/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/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=="], "@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=="],
@@ -5977,8 +5990,6 @@
"@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/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/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=="], "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
@@ -6013,18 +6024,14 @@
"@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/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": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], "@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-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/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/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/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/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/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=="], "@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=="],
@@ -6343,6 +6350,10 @@
"@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/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/@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=="], "@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
@@ -6567,8 +6578,6 @@
"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=="], "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/@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=="], "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=="],
@@ -6705,6 +6714,8 @@
"gcp-metadata/google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], "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=="], "gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -7369,6 +7380,10 @@
"@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], "@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/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=="], "@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=="],
@@ -7605,8 +7620,6 @@
"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=="], "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/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=="], "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,6 +161,7 @@
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@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", "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", "@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", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch",
+3 -1
View File
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
import { RequestExecutor } from "./executor" import { RequestExecutor } from "./executor"
import { Framing } from "./framing" import { Framing } from "./framing"
import { HttpTransport } from "./transport" import { HttpTransport } from "./transport"
import type { HttpMiddleware, Transport, TransportRuntime } from "./transport" import type { HttpMiddleware, HttpRequestTransform, Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport" import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol" import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy" import { applyCachePolicy } from "../cache-policy"
@@ -155,6 +155,7 @@ export interface Interface {
} }
export interface StreamOptions { export interface StreamOptions {
readonly transform?: HttpRequestTransform
readonly http?: HttpMiddleware readonly http?: HttpMiddleware
} }
@@ -307,6 +308,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
auth: routeInput.auth ?? Auth.none, auth: routeInput.auth ?? Auth.none,
encodeBody, encodeBody,
headers: routeInput.headers, headers: routeInput.headers,
transform: options?.transform,
middleware: options?.http, middleware: options?.http,
}), }),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => { streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
+1 -1
View File
@@ -270,7 +270,7 @@ const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: u
return transportError({ message: error.message, kind: "Timeout" }) return transportError({ message: error.message, kind: "Timeout" })
} }
if (!HttpClientError.isHttpClientError(error)) { if (!HttpClientError.isHttpClientError(error)) {
return transportError({ message: error instanceof Error ? error.message : "HTTP transport failed" }) return transportError({ message: "HTTP transport failed" })
} }
const request = "request" in error ? error.request : undefined const request = "request" in error ? error.request : undefined
if (error.reason._tag === "TransportError") { if (error.reason._tag === "TransportError") {
+8 -1
View File
@@ -23,4 +23,11 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
export type { Definition as EndpointFn, EndpointInput } from "./endpoint" export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
export type { Definition as FramingDef } from "./framing" export type { Definition as FramingDef } from "./framing"
export type { Protocol as ProtocolDef } from "./protocol" export type { Protocol as ProtocolDef } from "./protocol"
export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport" export type {
HttpHandler,
HttpMiddleware,
HttpRequest,
HttpRequestTransform,
Transport as TransportDef,
TransportRuntime,
} from "./transport"
+5 -3
View File
@@ -75,10 +75,12 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
prepare: (prepareInput) => prepare: (prepareInput) =>
Effect.gen(function* () { Effect.gen(function* () {
const parts = yield* jsonRequestParts({ ...prepareInput }) const parts = yield* jsonRequestParts({ ...prepareInput })
const transformed = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText }
yield* prepareInput.transform?.(transformed) ?? Effect.void
const request = ProviderShared.jsonPost({ const request = ProviderShared.jsonPost({
url: parts.url, url: transformed.url,
body: parts.bodyText, body: transformed.body ?? "",
headers: parts.headers, headers: Headers.fromInput(transformed.headers),
}) })
return { return {
request, request,
+10
View File
@@ -10,6 +10,15 @@ export interface TransportRuntime {
readonly webSocket?: WebSocketExecutorInterface readonly webSocket?: WebSocketExecutorInterface
} }
export interface HttpRequest {
url: string
readonly method: string
headers: Record<string, string>
body: string | undefined
}
export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect<void>
export interface Transport<Body, Prepared, Frame> { export interface Transport<Body, Prepared, Frame> {
readonly id: string readonly id: string
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, AIError> readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, AIError>
@@ -23,6 +32,7 @@ export interface TransportPrepareInput<Body> {
readonly auth: Auth.Definition readonly auth: Auth.Definition
readonly encodeBody: (body: Body) => string readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string> readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly transform?: HttpRequestTransform
readonly middleware?: HttpMiddleware readonly middleware?: HttpMiddleware
} }
+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> ) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
export interface ToolModelOutputInput<Parameters, Output> { export interface ToolModelOutputInput<Parameters, Output> {
readonly id: ToolCallPart["id"] readonly callID: ToolCallPart["id"]
readonly parameters: Parameters readonly parameters: Parameters
readonly output: Output readonly output: Output
} }
@@ -59,7 +59,7 @@ export interface Definition<Parameters extends ToolSchema<any>, Success extends
/** @internal */ /** @internal */
readonly _project: ( readonly _project: (
parameters: Schema.Schema.Type<Parameters>, parameters: Schema.Schema.Type<Parameters>,
id: ToolCallPart["id"], callID: ToolCallPart["id"],
output: unknown, output: unknown,
) => ToolOutputType ) => ToolOutputType
/** @internal */ /** @internal */
@@ -173,8 +173,8 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
toStructuredOutput: config.toStructuredOutput, toStructuredOutput: config.toStructuredOutput,
_decode: Effect.succeed, _decode: Effect.succeed,
_encode: Effect.succeed, _encode: Effect.succeed,
_project: (parameters, id, output) => _project: (parameters, callID, output) =>
project(config.toModelOutput, config.toStructuredOutput, parameters, id, output), project(config.toModelOutput, config.toStructuredOutput, parameters, callID, output),
_legacyResult: config.toModelOutput === undefined && config.toStructuredOutput === undefined, _legacyResult: config.toModelOutput === undefined && config.toStructuredOutput === undefined,
_definition: new ToolDefinition({ _definition: new ToolDefinition({
name: "", name: "",
@@ -193,8 +193,8 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
toStructuredOutput: config.toStructuredOutput, toStructuredOutput: config.toStructuredOutput,
_decode: Schema.decodeUnknownEffect(config.parameters), _decode: Schema.decodeUnknownEffect(config.parameters),
_encode: Schema.encodeEffect(config.success), _encode: Schema.encodeEffect(config.success),
_project: (parameters, id, output) => _project: (parameters, callID, output) =>
project(config.toModelOutput, config.toStructuredOutput, parameters, id, output), project(config.toModelOutput, config.toStructuredOutput, parameters, callID, output),
_legacyResult: false, _legacyResult: false,
_definition: new ToolDefinition({ _definition: new ToolDefinition({
name: "", name: "",
@@ -239,12 +239,12 @@ const project = (
toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<Tool.Content>) | undefined, toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<Tool.Content>) | undefined,
toStructuredOutput: ((output: unknown) => unknown) | undefined, toStructuredOutput: ((output: unknown) => unknown) | undefined,
parameters: unknown, parameters: unknown,
id: ToolCallPart["id"], callID: ToolCallPart["id"],
output: unknown, output: unknown,
): ToolOutputType => ): ToolOutputType =>
ToolOutput.make( ToolOutput.make(
toStructuredOutput?.(output) ?? output, toStructuredOutput?.(output) ?? output,
toModelOutput?.({ id, parameters, output }) ?? toModelOutput?.({ callID, parameters, output }) ??
(typeof output === "string" ? [{ type: "text", text: output }] : []), (typeof output === "string" ? [{ type: "text", text: output }] : []),
) )
-12
View File
@@ -67,18 +67,6 @@ const expectAIError = (error: unknown) => {
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined) const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
describe("RequestExecutor", () => { describe("RequestExecutor", () => {
it.effect("preserves middleware error messages", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor
.execute(request, () => Effect.fail(new Error("plugin rejected request")))
.pipe(Effect.flip)
expectAIError(error)
expect(error.reason.message).toBe("plugin rejected request")
}).pipe(Effect.provide(responsesLayer([]))),
)
it.effect("classifies context overflow responses", () => it.effect("classifies context overflow responses", () =>
Effect.gen(function* () { Effect.gen(function* () {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
+1 -1
View File
@@ -169,7 +169,7 @@ describe("LLMClient tools", () => {
LLMEvent.toolCall({ id: "call_projected", name: "projected", input: { prefix: "count" } }), LLMEvent.toolCall({ id: "call_projected", name: "projected", input: { prefix: "count" } }),
) )
expect(calls).toEqual([{ id: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }]) expect(calls).toEqual([{ callID: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }])
expect(dispatched.result).toEqual({ type: "text", value: "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.output).toEqual({ structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] })
expect(dispatched.events).toEqual([ expect(dispatched.events).toEqual([
+2 -2
View File
@@ -27,8 +27,8 @@ Tool.make({
parameters: Schema.Struct({ city: Schema.String }), parameters: Schema.Struct({ city: Schema.String }),
success: Schema.Struct({ forecast: Schema.NumberFromString }), success: Schema.Struct({ forecast: Schema.NumberFromString }),
execute: () => Effect.succeed({ forecast: 1 }), execute: () => Effect.succeed({ forecast: 1 }),
toModelOutput: ({ id, parameters, output }) => [ toModelOutput: ({ callID, parameters, output }) => [
{ type: "text", text: `${id}:${parameters.city}:${output.forecast}` }, { type: "text", text: `${callID}:${parameters.city}:${output.forecast}` },
], ],
}) })
+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") if (next.done) throw new Error("event stream disconnected during prompt execution")
const event = next.value const event = next.value
if (event.type === "permission.asked" && event.data.sessionID === input.sessionID) { if (event.type === "permission.asked" && event.data.sessionID === input.sessionID) {
const tool = event.data.source?.id ? tools.get(event.data.source.id) : undefined const tool = event.data.source?.callID ? tools.get(event.data.source.callID) : undefined
await replyPermission({ await replyPermission({
client: input.client, client: input.client,
connection: input.connection, connection: input.connection,
@@ -120,11 +120,11 @@ export async function streamTurn(input: {
} }
if (event.type === "session.tool.input.started") { if (event.type === "session.tool.input.started") {
assistantMessageID = event.data.assistantMessageID assistantMessageID = event.data.assistantMessageID
tools.set(event.data.id, { name: event.data.name, input: {}, metadata: {}, content: [] }) tools.set(event.data.callID, { name: event.data.name, input: {}, metadata: {}, content: [] })
await update({ await update({
sessionUpdate: "tool_call", sessionUpdate: "tool_call",
...pendingToolCall({ ...pendingToolCall({
toolCallId: event.data.id, toolCallId: event.data.callID,
toolName: event.data.name, toolName: event.data.name,
state: { input: {} }, state: { input: {} },
cwd: input.cwd, cwd: input.cwd,
@@ -134,13 +134,13 @@ export async function streamTurn(input: {
} }
if (event.type === "session.tool.called") { if (event.type === "session.tool.called") {
assistantMessageID = event.data.assistantMessageID assistantMessageID = event.data.assistantMessageID
const current = tools.get(event.data.id) ?? emptyToolState() const current = tools.get(event.data.callID) ?? emptyToolState()
current.input = event.data.input current.input = event.data.input
tools.set(event.data.id, current) tools.set(event.data.callID, current)
await update({ await update({
sessionUpdate: "tool_call_update", sessionUpdate: "tool_call_update",
...runningToolUpdate({ ...runningToolUpdate({
toolCallId: event.data.id, toolCallId: event.data.callID,
toolName: current.name, toolName: current.name,
state: { input: current.input }, state: { input: current.input },
cwd: input.cwd, cwd: input.cwd,
@@ -149,13 +149,13 @@ export async function streamTurn(input: {
continue continue
} }
if (event.type === "session.tool.progress") { if (event.type === "session.tool.progress") {
const current = tools.get(event.data.id) const current = tools.get(event.data.callID)
if (!current) continue if (!current) continue
current.metadata = event.data.metadata current.metadata = event.data.metadata
await update({ await update({
sessionUpdate: "tool_call_update", sessionUpdate: "tool_call_update",
...runningToolUpdate({ ...runningToolUpdate({
toolCallId: event.data.id, toolCallId: event.data.callID,
toolName: current.name, toolName: current.name,
state: { input: current.input }, state: { input: current.input },
cwd: input.cwd, cwd: input.cwd,
@@ -164,8 +164,8 @@ export async function streamTurn(input: {
continue continue
} }
if (event.type === "session.tool.success") { if (event.type === "session.tool.success") {
const current = tools.get(event.data.id) ?? emptyToolState() const current = tools.get(event.data.callID) ?? emptyToolState()
tools.delete(event.data.id) tools.delete(event.data.callID)
await syncEditedFiles({ await syncEditedFiles({
connection: input.connection, connection: input.connection,
writeTextFile: input.writeTextFile, writeTextFile: input.writeTextFile,
@@ -178,7 +178,7 @@ export async function streamTurn(input: {
await update({ await update({
sessionUpdate: "tool_call_update", sessionUpdate: "tool_call_update",
...completedToolUpdate({ ...completedToolUpdate({
toolCallId: event.data.id, toolCallId: event.data.callID,
toolName: current.name, toolName: current.name,
input: current.input, input: current.input,
metadata: event.data.metadata, metadata: event.data.metadata,
@@ -188,12 +188,12 @@ export async function streamTurn(input: {
continue continue
} }
if (event.type === "session.tool.failed") { if (event.type === "session.tool.failed") {
const current = tools.get(event.data.id) ?? emptyToolState() const current = tools.get(event.data.callID) ?? emptyToolState()
tools.delete(event.data.id) tools.delete(event.data.callID)
await update({ await update({
sessionUpdate: "tool_call_update", sessionUpdate: "tool_call_update",
...errorToolUpdate({ ...errorToolUpdate({
toolCallId: event.data.id, toolCallId: event.data.callID,
toolName: current.name, toolName: current.name,
input: current.input, input: current.input,
metadata: event.data.metadata ?? current.metadata, metadata: event.data.metadata ?? current.metadata,
+1 -1
View File
@@ -31,7 +31,7 @@ export async function replyPermission(input: {
sessionId: input.sessionID, sessionId: input.sessionID,
toolCall: { toolCall: {
...pendingToolCall({ ...pendingToolCall({
toolCallId: input.event.data.source?.id ?? input.event.data.id, toolCallId: input.event.data.source?.callID ?? input.event.data.id,
toolName, toolName,
state: { input: toolInput, title: permissionTitle(toolName, toolInput, previews) }, state: { input: toolInput, title: permissionTitle(toolName, toolInput, previews) },
cwd: input.cwd, cwd: input.cwd,
+18 -18
View File
@@ -300,7 +300,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.tool.input.started") { if (event.type === "session.tool.input.started") {
flushStep() flushStep()
tools.set(toolKey(event.data.assistantMessageID, event.data.id), { tools.set(toolKey(event.data.assistantMessageID, event.data.callID), {
id: partID(event.id), id: partID(event.id),
timestamp: time, timestamp: time,
assistantMessageID: event.data.assistantMessageID, assistantMessageID: event.data.assistantMessageID,
@@ -312,18 +312,18 @@ export async function runNonInteractivePrompt(input: Input) {
continue continue
} }
if (event.type === "session.tool.input.ended") { if (event.type === "session.tool.input.ended") {
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.id)) const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
if (current) current.raw = event.data.text if (current) current.raw = event.data.text
continue continue
} }
if (event.type === "session.tool.input.delta") { if (event.type === "session.tool.input.delta") {
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.id)) const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
if (current) current.raw = (current.raw ?? "") + event.data.delta if (current) current.raw = (current.raw ?? "") + event.data.delta
continue continue
} }
if (event.type === "session.tool.called") { if (event.type === "session.tool.called") {
flushStep() flushStep()
const key = toolKey(event.data.assistantMessageID, event.data.id) const key = toolKey(event.data.assistantMessageID, event.data.callID)
const current = tools.get(key) const current = tools.get(key)
tools.set(key, { tools.set(key, {
id: current?.id ?? partID(event.id), id: current?.id ?? partID(event.id),
@@ -340,18 +340,18 @@ export async function runNonInteractivePrompt(input: Input) {
continue continue
} }
if (event.type === "session.tool.progress") { if (event.type === "session.tool.progress") {
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.id)) const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
if (current) { if (current) {
current.metadata = event.data.metadata current.metadata = event.data.metadata
} }
continue continue
} }
if (event.type === "session.tool.success") { if (event.type === "session.tool.success") {
const key = toolKey(event.data.assistantMessageID, event.data.id) const key = toolKey(event.data.assistantMessageID, event.data.callID)
const current = tools.get(key) ?? fallbackTool(event) const current = tools.get(key) ?? fallbackTool(event)
const tool: SessionMessageAssistantTool = { const tool: SessionMessageAssistantTool = {
type: "tool", type: "tool",
id: event.data.id, id: event.data.callID,
name: current.tool, name: current.tool,
executed: event.data.executed, executed: event.data.executed,
providerState: current.providerState, providerState: current.providerState,
@@ -365,11 +365,11 @@ export async function runNonInteractivePrompt(input: Input) {
time: { created: current.timestamp, ran: current.timestamp, completed: time }, time: { created: current.timestamp, ran: current.timestamp, completed: time },
} }
const part: MiniToolPart = { const part: MiniToolPart = {
partID: current.id, id: current.id,
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
type: "tool", type: "tool",
id: event.data.id, callID: event.data.callID,
tool: current.tool, tool: current.tool,
state: { state: {
status: "completed", status: "completed",
@@ -392,14 +392,14 @@ export async function runNonInteractivePrompt(input: Input) {
continue continue
} }
if (event.type === "session.tool.failed") { if (event.type === "session.tool.failed") {
const key = toolKey(event.data.assistantMessageID, event.data.id) const key = toolKey(event.data.assistantMessageID, event.data.callID)
const current = tools.get(key) ?? fallbackTool(event) const current = tools.get(key) ?? fallbackTool(event)
const error = event.data.error.message const error = event.data.error.message
const metadata = event.data.metadata ?? current.metadata const metadata = event.data.metadata ?? current.metadata
const content = event.data.content ?? nonEmptyToolContent(current.content) const content = event.data.content ?? nonEmptyToolContent(current.content)
const tool: SessionMessageAssistantTool = { const tool: SessionMessageAssistantTool = {
type: "tool", type: "tool",
id: event.data.id, id: event.data.callID,
name: current.tool, name: current.tool,
executed: event.data.executed, executed: event.data.executed,
providerState: current.providerState, providerState: current.providerState,
@@ -414,11 +414,11 @@ export async function runNonInteractivePrompt(input: Input) {
time: { created: current.timestamp, ran: current.timestamp, completed: time }, time: { created: current.timestamp, ran: current.timestamp, completed: time },
} }
const part: MiniToolPart = { const part: MiniToolPart = {
partID: current.id, id: current.id,
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
type: "tool", type: "tool",
id: event.data.id, callID: event.data.callID,
tool: current.tool, tool: current.tool,
state: { state: {
status: "error", status: "error",
@@ -578,11 +578,11 @@ export async function runNonInteractivePrompt(input: Input) {
const key = toolKey(message.id, item.id) const key = toolKey(message.id, item.id)
if (renderedTools.has(key) || item.state.status === "streaming" || item.state.status === "running") continue if (renderedTools.has(key) || item.state.status === "streaming" || item.state.status === "running") continue
const part: MiniToolPart = { const part: MiniToolPart = {
partID: projectedPartID(message.id, `tool-${item.id}`), id: projectedPartID(message.id, `tool-${item.id}`),
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: message.id, messageID: message.id,
type: "tool", type: "tool",
id: item.id, callID: item.id,
tool: item.name, tool: item.name,
state: state:
item.state.status === "completed" item.state.status === "completed"
@@ -771,8 +771,8 @@ function partID(eventID: string) {
return `prt_${eventID.replace(/^evt_/, "")}` return `prt_${eventID.replace(/^evt_/, "")}`
} }
function toolKey(messageID: string, id: string) { function toolKey(messageID: string, callID: string) {
return `${messageID}\u0000${id}` return `${messageID}\u0000${callID}`
} }
function contentKey(messageID: string, ordinal: number) { function contentKey(messageID: string, ordinal: number) {
@@ -786,7 +786,7 @@ function projectedPartID(messageID: string, part: string) {
function fallbackTool(event: { function fallbackTool(event: {
id: string id: string
created: number created: number
data: { assistantMessageID: string; id: string } data: { assistantMessageID: string; callID: string }
}): ToolState { }): ToolState {
return { return {
id: partID(event.id), id: partID(event.id),
+8 -8
View File
@@ -200,7 +200,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.input.started", { durableEvent("session.tool.input.started", {
sessionID: "ses_tools", sessionID: "ses_tools",
assistantMessageID: "msg_tools", assistantMessageID: "msg_tools",
id: "call_ok", callID: "call_ok",
name: "shell", name: "shell",
}), }),
) )
@@ -208,7 +208,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.called", { durableEvent("session.tool.called", {
sessionID: "ses_tools", sessionID: "ses_tools",
assistantMessageID: "msg_tools", assistantMessageID: "msg_tools",
id: "call_ok", callID: "call_ok",
input: { command: "printf done", workdir: "sub" }, input: { command: "printf done", workdir: "sub" },
executed: false, executed: false,
}), }),
@@ -217,7 +217,7 @@ describe("acp event behavior", () => {
ephemeralEvent("session.tool.progress", { ephemeralEvent("session.tool.progress", {
sessionID: "ses_tools", sessionID: "ses_tools",
assistantMessageID: "msg_tools", assistantMessageID: "msg_tools",
id: "call_ok", callID: "call_ok",
metadata: { phase: 1 }, metadata: { phase: 1 },
}), }),
) )
@@ -225,7 +225,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.success", { durableEvent("session.tool.success", {
sessionID: "ses_tools", sessionID: "ses_tools",
assistantMessageID: "msg_tools", assistantMessageID: "msg_tools",
id: "call_ok", callID: "call_ok",
metadata: { exit: 0 }, metadata: { exit: 0 },
content: [{ type: "text", text: "done" }], content: [{ type: "text", text: "done" }],
executed: true, executed: true,
@@ -235,7 +235,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.input.started", { durableEvent("session.tool.input.started", {
sessionID: "ses_tools", sessionID: "ses_tools",
assistantMessageID: "msg_tools", assistantMessageID: "msg_tools",
id: "call_fail", callID: "call_fail",
name: "read", name: "read",
}), }),
) )
@@ -243,7 +243,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.called", { durableEvent("session.tool.called", {
sessionID: "ses_tools", sessionID: "ses_tools",
assistantMessageID: "msg_tools", assistantMessageID: "msg_tools",
id: "call_fail", callID: "call_fail",
input: { path: "/workspace/missing.ts" }, input: { path: "/workspace/missing.ts" },
executed: false, executed: false,
}), }),
@@ -252,7 +252,7 @@ describe("acp event behavior", () => {
ephemeralEvent("session.tool.progress", { ephemeralEvent("session.tool.progress", {
sessionID: "ses_tools", sessionID: "ses_tools",
assistantMessageID: "msg_tools", assistantMessageID: "msg_tools",
id: "call_fail", callID: "call_fail",
metadata: { bytes: 0 }, metadata: { bytes: 0 },
}), }),
) )
@@ -260,7 +260,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.failed", { durableEvent("session.tool.failed", {
sessionID: "ses_tools", sessionID: "ses_tools",
assistantMessageID: "msg_tools", assistantMessageID: "msg_tools",
id: "call_fail", callID: "call_fail",
error: { type: "tool.error", message: "not found" }, error: { type: "tool.error", message: "not found" },
metadata: { bytes: 0 }, metadata: { bytes: 0 },
content: [{ type: "text", text: "opening" }], content: [{ type: "text", text: "opening" }],
@@ -43,14 +43,14 @@ describe("acp permission behavior", () => {
permissionAsked("ses_allow", "perm_once", { permissionAsked("ses_allow", "perm_once", {
action: "shell", action: "shell",
metadata: { command: "printf hello" }, metadata: { command: "printf hello" },
source: { type: "tool", messageID: "msg_allow", id: "call_once" }, source: { type: "tool", messageID: "msg_allow", callID: "call_once" },
}), }),
) )
send( send(
permissionAsked("ses_allow", "perm_always", { permissionAsked("ses_allow", "perm_always", {
action: "read", action: "read",
metadata: { path: "/workspace/file.ts" }, metadata: { path: "/workspace/file.ts" },
source: { type: "tool", messageID: "msg_allow", id: "call_always" }, source: { type: "tool", messageID: "msg_allow", callID: "call_always" },
}), }),
) )
send(durableEvent("session.execution.succeeded", { sessionID: "ses_allow" })) send(durableEvent("session.execution.succeeded", { sessionID: "ses_allow" }))
@@ -166,7 +166,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.input.started", { durableEvent("session.tool.input.started", {
sessionID: "ses_edit", sessionID: "ses_edit",
assistantMessageID: "msg_edit", assistantMessageID: "msg_edit",
id: "call_edit", callID: "call_edit",
name: "edit", name: "edit",
}), }),
) )
@@ -174,7 +174,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.called", { durableEvent("session.tool.called", {
sessionID: "ses_edit", sessionID: "ses_edit",
assistantMessageID: "msg_edit", assistantMessageID: "msg_edit",
id: "call_edit", callID: "call_edit",
input: { path: "file.ts", oldString: "before", newString: "after" }, input: { path: "file.ts", oldString: "before", newString: "after" },
executed: false, executed: false,
}), }),
@@ -182,7 +182,7 @@ describe("acp permission behavior", () => {
send( send(
permissionAsked("ses_edit", "perm_edit", { permissionAsked("ses_edit", "perm_edit", {
action: "edit", action: "edit",
source: { type: "tool", messageID: "msg_edit", id: "call_edit" }, source: { type: "tool", messageID: "msg_edit", callID: "call_edit" },
}), }),
) )
}, },
@@ -192,7 +192,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.success", { durableEvent("session.tool.success", {
sessionID: "ses_edit", sessionID: "ses_edit",
assistantMessageID: "msg_edit", assistantMessageID: "msg_edit",
id: "call_edit", callID: "call_edit",
metadata: { files: [{ file: "file.ts" }], replacements: 1 }, metadata: { files: [{ file: "file.ts" }], replacements: 1 },
content: [{ type: "text", text: "edited" }], content: [{ type: "text", text: "edited" }],
executed: true, executed: true,
@@ -256,7 +256,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.input.started", { durableEvent("session.tool.input.started", {
sessionID: "ses_patch", sessionID: "ses_patch",
assistantMessageID: "msg_patch", assistantMessageID: "msg_patch",
id: "call_patch", callID: "call_patch",
name: "patch", name: "patch",
}), }),
) )
@@ -264,7 +264,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.called", { durableEvent("session.tool.called", {
sessionID: "ses_patch", sessionID: "ses_patch",
assistantMessageID: "msg_patch", assistantMessageID: "msg_patch",
id: "call_patch", callID: "call_patch",
input: { patchText }, input: { patchText },
executed: false, executed: false,
}), }),
@@ -272,7 +272,7 @@ describe("acp permission behavior", () => {
send( send(
permissionAsked("ses_patch", "perm_patch", { permissionAsked("ses_patch", "perm_patch", {
action: "edit", action: "edit",
source: { type: "tool", messageID: "msg_patch", id: "call_patch" }, source: { type: "tool", messageID: "msg_patch", callID: "call_patch" },
}), }),
) )
}, },
@@ -285,7 +285,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.success", { durableEvent("session.tool.success", {
sessionID: "ses_patch", sessionID: "ses_patch",
assistantMessageID: "msg_patch", assistantMessageID: "msg_patch",
id: "call_patch", callID: "call_patch",
metadata: { files: [{ file: "first.ts" }, { file: "second.ts" }] }, metadata: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
content: [{ type: "text", text: "patched" }], content: [{ type: "text", text: "patched" }],
executed: true, executed: true,
@@ -499,7 +499,7 @@ function permissionAsked(
input: { input: {
readonly action?: string readonly action?: string
readonly metadata?: Record<string, unknown> readonly metadata?: Record<string, unknown>
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string } readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
} = {}, } = {},
) { ) {
return ephemeralEvent("permission.asked", { return ephemeralEvent("permission.asked", {
+8 -8
View File
@@ -109,7 +109,7 @@ function failedTool(inputID: string): V2Event[] {
data: { data: {
sessionID: "ses_1", sessionID: "ses_1",
assistantMessageID: "msg_failed_tool", assistantMessageID: "msg_failed_tool",
id: "call_failed_tool", callID: "call_failed_tool",
name: "shell", name: "shell",
}, },
}, },
@@ -121,7 +121,7 @@ function failedTool(inputID: string): V2Event[] {
data: { data: {
sessionID: "ses_1", sessionID: "ses_1",
assistantMessageID: "msg_failed_tool", assistantMessageID: "msg_failed_tool",
id: "call_failed_tool", callID: "call_failed_tool",
input: { command: "printf partial && false" }, input: { command: "printf partial && false" },
executed: true, executed: true,
}, },
@@ -133,7 +133,7 @@ function failedTool(inputID: string): V2Event[] {
data: { data: {
sessionID: "ses_1", sessionID: "ses_1",
assistantMessageID: "msg_failed_tool", assistantMessageID: "msg_failed_tool",
id: "call_failed_tool", callID: "call_failed_tool",
metadata: { checkpoint: 1 }, metadata: { checkpoint: 1 },
}, },
}, },
@@ -145,7 +145,7 @@ function failedTool(inputID: string): V2Event[] {
data: { data: {
sessionID: "ses_1", sessionID: "ses_1",
assistantMessageID: "msg_failed_tool", assistantMessageID: "msg_failed_tool",
id: "call_failed_tool", callID: "call_failed_tool",
error: { type: "unknown", message: "tool failed" }, error: { type: "unknown", message: "tool failed" },
metadata: { checkpoint: 1 }, metadata: { checkpoint: 1 },
content: [{ type: "text", text: "partial output" }], content: [{ type: "text", text: "partial output" }],
@@ -168,7 +168,7 @@ function successfulGrep(inputID: string): V2Event[] {
data: { data: {
sessionID: "ses_1", sessionID: "ses_1",
assistantMessageID: "msg_grep", assistantMessageID: "msg_grep",
id: "call_grep", callID: "call_grep",
name: "grep", name: "grep",
}, },
}, },
@@ -180,7 +180,7 @@ function successfulGrep(inputID: string): V2Event[] {
data: { data: {
sessionID: "ses_1", sessionID: "ses_1",
assistantMessageID: "msg_grep", assistantMessageID: "msg_grep",
id: "call_grep", callID: "call_grep",
input: { pattern: "needle" }, input: { pattern: "needle" },
executed: true, executed: true,
}, },
@@ -193,7 +193,7 @@ function successfulGrep(inputID: string): V2Event[] {
data: { data: {
sessionID: "ses_1", sessionID: "ses_1",
assistantMessageID: "msg_grep", assistantMessageID: "msg_grep",
id: "call_grep", callID: "call_grep",
metadata: { matches: 2 }, metadata: { matches: 2 },
content: [{ type: "text", text }], content: [{ type: "text", text }],
executed: false, executed: false,
@@ -561,7 +561,7 @@ describe("runNonInteractivePrompt", () => {
type: "tool_use", type: "tool_use",
part: { part: {
type: "tool", type: "tool",
id: "call_failed_tool", callID: "call_failed_tool",
tool: "shell", tool: "shell",
state: { state: {
status: "error", status: "error",
+9 -24
View File
@@ -605,7 +605,7 @@ export type Endpoint5_26Output =
readonly data: { readonly data: {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID readonly assistantMessageID: SessionMessage.ID
readonly id: string readonly callID: string
readonly name: string readonly name: string
} }
} }
@@ -619,7 +619,7 @@ export type Endpoint5_26Output =
readonly data: { readonly data: {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID readonly assistantMessageID: SessionMessage.ID
readonly id: string readonly callID: string
readonly text: string readonly text: string
} }
} }
@@ -633,7 +633,7 @@ export type Endpoint5_26Output =
readonly data: { readonly data: {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID readonly assistantMessageID: SessionMessage.ID
readonly id: string readonly callID: string
readonly input: { readonly [x: string]: unknown } readonly input: { readonly [x: string]: unknown }
readonly executed: boolean readonly executed: boolean
readonly state?: SessionMessage.ProviderState | undefined readonly state?: SessionMessage.ProviderState | undefined
@@ -649,7 +649,7 @@ export type Endpoint5_26Output =
readonly data: { readonly data: {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID readonly assistantMessageID: SessionMessage.ID
readonly id: string readonly callID: string
readonly content: readonly [ readonly content: readonly [
( (
| { readonly type: "text"; readonly text: string } | { readonly type: "text"; readonly text: string }
@@ -685,7 +685,7 @@ export type Endpoint5_26Output =
readonly data: { readonly data: {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID readonly assistantMessageID: SessionMessage.ID
readonly id: string readonly callID: string
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined } readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly content?: readonly content?:
| readonly [ | readonly [
@@ -764,14 +764,6 @@ export type Endpoint5_26Output =
readonly reason: "auto" | "manual" readonly reason: "auto" | "manual"
readonly text: string readonly text: string
readonly recent: string readonly recent: string
readonly media?:
| ReadonlyArray<{
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | undefined
}>
| undefined
} }
} }
| { | {
@@ -1510,25 +1502,18 @@ export interface ProjectCopyApi<E = never> {
export type Endpoint25_0Input = { export type Endpoint25_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info } export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E> export type VcsStatusOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
export type Endpoint25_1Input = { export type Endpoint25_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export type Endpoint25_2Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: Vcs.Mode readonly mode: Vcs.Mode
readonly context?: number | undefined readonly context?: number | undefined
} }
export type Endpoint25_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> } export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
export type VcsDiffOperation<E = never> = (input: Endpoint25_2Input) => Effect.Effect<Endpoint25_2Output, E> export type VcsDiffOperation<E = never> = (input: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export interface VcsApi<E = never> { export interface VcsApi<E = never> {
readonly get: VcsGetOperation<E>
readonly status: VcsStatusOperation<E> readonly status: VcsStatusOperation<E>
readonly diff: VcsDiffOperation<E> readonly diff: VcsDiffOperation<E>
} }
+3 -14
View File
@@ -210,8 +210,6 @@ import type {
Endpoint25_0Output, Endpoint25_0Output,
Endpoint25_1Input, Endpoint25_1Input,
Endpoint25_1Output, Endpoint25_1Output,
Endpoint25_2Input,
Endpoint25_2Output,
Endpoint26_0Output, Endpoint26_0Output,
Endpoint26_1Input, Endpoint26_1Input,
Endpoint26_1Output, Endpoint26_1Output,
@@ -1184,26 +1182,17 @@ const adaptGroup24 = (raw: RawClient["server.projectCopy"]) => ({
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) => const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
preserveEffect<Endpoint25_0Output>()( preserveEffect<Endpoint25_0Output>()(
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_1Input) =>
preserveEffect<Endpoint25_1Output>()(
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)), raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint25_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_2Input) => const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_1Input) =>
preserveEffect<Endpoint25_2Output>()( preserveEffect<Endpoint25_1Output>()(
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe( raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
), ),
) )
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({ const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint25_0(raw), diff: Endpoint25_1(raw) })
get: Endpoint25_0(raw),
status: Endpoint25_1(raw),
diff: Endpoint25_2(raw),
})
const Endpoint26_0 = (raw: RawClient["server.debug"]) => () => const Endpoint26_0 = (raw: RawClient["server.debug"]) => () =>
preserveEffect<Endpoint26_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError))) preserveEffect<Endpoint26_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
+5 -2
View File
@@ -56,6 +56,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
let announced = false let announced = false
let lastSpawn = 0 let lastSpawn = 0
let spawnDelay = 5_000 let spawnDelay = 5_000
let ownerHeld = false
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
Effect.sync(() => { Effect.sync(() => {
if (announced) return if (announced) return
@@ -83,6 +84,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
const info = registration.info const info = registration.info
const service = registration.service const service = registration.service
if (service !== undefined) { if (service !== undefined) {
ownerHeld = false
spawnDelay = 5_000 spawnDelay = 5_000
const compatible = !service.legacy && (options.version === undefined || service.version === options.version) const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
if (compatible && service.state === "ready") return Option.some(service) if (compatible && service.state === "ready") return Option.some(service)
@@ -95,13 +97,14 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
return Option.none<LocalService>() return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now() } 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 finished = [...contenders].filter(contenderFinished)
const failure = finished.map(contenderFailure).find((error): error is Error => error !== undefined)
if (finished.some((item) => item.child.exitCode === 0)) { if (finished.some((item) => item.child.exitCode === 0)) {
ownerHeld = true
spawnDelay = Math.min(spawnDelay * 2, 30_000) spawnDelay = Math.min(spawnDelay * 2, 30_000)
} }
finished.forEach((item) => contenders.delete(item)) 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. // Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) { if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
yield* announce("missing") yield* announce("missing")
@@ -202,8 +202,6 @@ import type {
ProjectCopyRemoveOutput, ProjectCopyRemoveOutput,
ProjectCopyRefreshInput, ProjectCopyRefreshInput,
ProjectCopyRefreshOutput, ProjectCopyRefreshOutput,
VcsGetInput,
VcsGetOutput,
VcsStatusInput, VcsStatusInput,
VcsStatusOutput, VcsStatusOutput,
VcsDiffInput, VcsDiffInput,
@@ -1704,18 +1702,6 @@ export function make(options: ClientOptions) {
), ),
}, },
vcs: { vcs: {
get: (input?: VcsGetInput, requestOptions?: RequestOptions) =>
request<VcsGetOutput>(
{
method: "GET",
path: `/api/vcs`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
status: (input?: VcsStatusInput, requestOptions?: RequestOptions) => status: (input?: VcsStatusInput, requestOptions?: RequestOptions) =>
request<VcsStatusOutput>( request<VcsStatusOutput>(
{ {
+37 -53
View File
@@ -121,6 +121,17 @@ export type SessionMessageCompactionRunning = {
recent: string recent: string
} }
export type SessionMessageCompactionCompleted = {
type: "compaction"
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
summary: string
recent: string
}
export type InstructionEntryKey = string export type InstructionEntryKey = string
export type SessionGenerateResponse = { data: { text: string } } export type SessionGenerateResponse = { data: { text: string } }
@@ -286,7 +297,7 @@ export type FormExternalField = { key: string; type: "external"; url: string; ti
export type FormValue = string | number | boolean | Array<string> export type FormValue = string | number | boolean | Array<string>
export type PermissionSource = { type: "tool"; messageID: string; id: string } export type PermissionSource = { type: "tool"; messageID: string; callID: string }
export type PermissionSavedInfo = { id: string; projectID: string; action: string; resource: string } export type PermissionSavedInfo = { id: string; projectID: string; action: string; resource: string }
@@ -475,7 +486,7 @@ export type Pty = {
export type QuestionOption = { label: string; description: string } export type QuestionOption = { label: string; description: string }
export type QuestionTool = { messageID: string; id: string } export type QuestionTool = { messageID: string; callID: string }
export type QuestionAnswer = Array<string> export type QuestionAnswer = Array<string>
@@ -519,8 +530,6 @@ export type ReferenceGitSource = {
export type ProjectCopyCopy = { directory: string } export type ProjectCopyCopy = { directory: string }
export type VcsBranch = { current?: string; default?: string }
export type VcsFileStatus = { export type VcsFileStatus = {
file: string file: string
additions: number additions: number
@@ -733,7 +742,7 @@ export type SessionToolInputStarted = {
type: "session.tool.input.started" type: "session.tool.input.started"
durable: { aggregateID: string; seq: number; version: 1 } durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef location?: LocationRef
data: { sessionID: string; assistantMessageID: string; id: string; name: string } data: { sessionID: string; assistantMessageID: string; callID: string; name: string }
} }
export type SessionToolInputEnded = { export type SessionToolInputEnded = {
@@ -743,7 +752,7 @@ export type SessionToolInputEnded = {
type: "session.tool.input.ended" type: "session.tool.input.ended"
durable: { aggregateID: string; seq: number; version: 1 } durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef location?: LocationRef
data: { sessionID: string; assistantMessageID: string; id: string; text: string } data: { sessionID: string; assistantMessageID: string; callID: string; text: string }
} }
export type SessionCompactionAdmitted = { export type SessionCompactionAdmitted = {
@@ -766,6 +775,16 @@ export type SessionCompactionStarted = {
data: { sessionID: string; reason: "auto" | "manual"; recent: string; inputID?: string } data: { sessionID: string; reason: "auto" | "manual"; recent: string; inputID?: string }
} }
export type SessionCompactionEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; reason: "auto" | "manual"; text: string; recent: string }
}
export type SessionRevertCleared = { export type SessionRevertCleared = {
id: string id: string
created: number created: number
@@ -894,7 +913,7 @@ export type SessionToolInputDelta = {
metadata?: { [x: string]: any } metadata?: { [x: string]: any }
type: "session.tool.input.delta" type: "session.tool.input.delta"
location?: LocationRef location?: LocationRef
data: { sessionID: string; assistantMessageID: string; id: string; delta: string } data: { sessionID: string; assistantMessageID: string; callID: string; delta: string }
} }
export type SessionToolProgress = { export type SessionToolProgress = {
@@ -903,7 +922,7 @@ export type SessionToolProgress = {
metadata?: { [x: string]: any } metadata?: { [x: string]: any }
type: "session.tool.progress" type: "session.tool.progress"
location?: LocationRef location?: LocationRef
data: { sessionID: string; assistantMessageID: string; id: string; metadata: { [x: string]: JsonValue } } data: { sessionID: string; assistantMessageID: string; callID: string; metadata: { [x: string]: JsonValue } }
} }
export type SessionCompactionDelta = { export type SessionCompactionDelta = {
@@ -1206,18 +1225,6 @@ export type SessionMessageAssistantReasoning = {
export type ToolContent = ToolTextContent | ToolFileContent export type ToolContent = ToolTextContent | ToolFileContent
export type SessionMessageCompactionCompleted = {
type: "compaction"
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
summary: string
recent: string
media?: Array<ToolFileContent>
}
export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError } export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError }
export type SessionMessageCompactionFailed = { export type SessionMessageCompactionFailed = {
@@ -1371,7 +1378,7 @@ export type SessionToolCalled = {
data: { data: {
sessionID: string sessionID: string
assistantMessageID: string assistantMessageID: string
id: string callID: string
input: { [x: string]: any } input: { [x: string]: any }
executed: boolean executed: boolean
state?: SessionMessageProviderState7 state?: SessionMessageProviderState7
@@ -1380,16 +1387,6 @@ export type SessionToolCalled = {
export type ToolContent1 = ToolTextContent | ToolFileContent1 export type ToolContent1 = ToolTextContent | ToolFileContent1
export type SessionCompactionEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; reason: "auto" | "manual"; text: string; recent: string; media?: Array<ToolFileContent1> }
}
export type ModelCompatibility = { reasoningField?: ModelReasoningField } export type ModelCompatibility = { reasoningField?: ModelReasoningField }
export type ModelCost = { export type ModelCost = {
@@ -1748,8 +1745,6 @@ export type SessionStatus2 = {
export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource
export type VcsInfo = { branch: VcsBranch }
export type PermissionRuleset = Array<PermissionRule> export type PermissionRuleset = Array<PermissionRule>
export type SessionInfo = { export type SessionInfo = {
@@ -1832,7 +1827,7 @@ export type SessionToolSuccess = {
data: { data: {
sessionID: string sessionID: string
assistantMessageID: string assistantMessageID: string
id: string callID: string
content: [ToolContent1, ...Array<ToolContent1>] content: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue } metadata?: { [x: string]: JsonValue }
executed: boolean executed: boolean
@@ -1850,7 +1845,7 @@ export type SessionToolFailed = {
data: { data: {
sessionID: string sessionID: string
assistantMessageID: string assistantMessageID: string
id: string callID: string
error: SessionStructuredError error: SessionStructuredError
content?: [ToolContent1, ...Array<ToolContent1>] content?: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue } metadata?: { [x: string]: JsonValue }
@@ -4473,7 +4468,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string> readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string> readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string } readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly agent?: string | null readonly agent?: string | null
}["id"] }["id"]
readonly action: { readonly action: {
@@ -4482,7 +4477,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string> readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string> readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string } readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly agent?: string | null readonly agent?: string | null
}["action"] }["action"]
readonly resources: { readonly resources: {
@@ -4491,7 +4486,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string> readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string> readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string } readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly agent?: string | null readonly agent?: string | null
}["resources"] }["resources"]
readonly save?: { readonly save?: {
@@ -4500,7 +4495,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string> readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string> readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string } readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly agent?: string | null readonly agent?: string | null
}["save"] }["save"]
readonly metadata?: { readonly metadata?: {
@@ -4509,7 +4504,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string> readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string> readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string } readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly agent?: string | null readonly agent?: string | null
}["metadata"] }["metadata"]
readonly source?: { readonly source?: {
@@ -4518,7 +4513,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string> readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string> readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string } readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly agent?: string | null readonly agent?: string | null
}["source"] }["source"]
readonly agent?: { readonly agent?: {
@@ -4527,7 +4522,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string> readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string> readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string } readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly agent?: string | null readonly agent?: string | null
}["agent"] }["agent"]
} }
@@ -4907,17 +4902,6 @@ export type ProjectCopyRefreshInput = {
export type ProjectCopyRefreshOutput = void export type ProjectCopyRefreshOutput = void
export type VcsGetInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type VcsGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: VcsInfo
}
export type VcsStatusInput = { export type VcsStatusInput = {
readonly location?: { readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+5 -2
View File
@@ -37,6 +37,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
let announced = false let announced = false
let lastSpawn = 0 let lastSpawn = 0
let spawnDelay = 5_000 let spawnDelay = 5_000
let ownerHeld = false
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => { const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
if (announced) return if (announced) return
@@ -64,6 +65,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const registration = await registered(options.file, true) const registration = await registered(options.file, true)
if (registration.service !== undefined) { if (registration.service !== undefined) {
ownerHeld = false
spawnDelay = 5_000 spawnDelay = 5_000
const service = registration.service const service = registration.service
const compatible = !service.legacy && (options.version === undefined || service.version === options.version) const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
@@ -76,13 +78,14 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
} }
} else { } else {
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now() 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 finished = [...contenders].filter(contenderFinished)
const failure = finished.map(contenderFailure).find((error) => error !== undefined)
if (finished.some((item) => item.child.exitCode === 0)) { if (finished.some((item) => item.child.exitCode === 0)) {
ownerHeld = true
spawnDelay = Math.min(spawnDelay * 2, 30_000) spawnDelay = Math.min(spawnDelay * 2, 30_000)
} }
finished.forEach((item) => contenders.delete(item)) 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. // Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) { if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
announce("missing") announce("missing")
+3 -9
View File
@@ -9,20 +9,14 @@ if (mode === "record-start") {
} }
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL") if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
if ( if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated") {
mode === "delayed" ||
mode === "delayed-failed" ||
mode === "coordinated" ||
mode === "coordinated-failed-loser"
) {
await appendFile(registration + ".starts", process.pid + "\n") await appendFile(registration + ".starts", process.pid + "\n")
const owner = await writeFile(registration + ".owner", String(process.pid), { flag: "wx" }) const owner = await writeFile(registration + ".owner", String(process.pid), { flag: "wx" })
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false)
if (!owner) process.exit(mode === "coordinated-failed-loser" ? 1 : 0) if (!owner) process.exit()
if (mode === "coordinated" || mode === "coordinated-failed-loser") { if (mode === "coordinated") {
while ((await Bun.file(registration + ".starts").text()).trim().split("\n").length < 2) await Bun.sleep(10) 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)) } else await Bun.sleep(Number(delay))
if (mode === "delayed-failed") process.exit(1) if (mode === "delayed-failed") process.exit(1)
} }
@@ -44,24 +44,6 @@ test("ensures a missing service with native promises", async () => {
} }
}, 15_000) }, 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 () => { test("reports a failed registered service", async () => {
const registration = await setup("failed-owner") const registration = await setup("failed-owner")
+1 -1
View File
@@ -44,7 +44,7 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"]) expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
expect(Object.keys(client.websearch)).toEqual(["providers", "query"]) expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
expect(Object.keys(client.file)).toEqual(["read", "list", "find"]) expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
expect(Object.keys(client.vcs)).toEqual(["get", "status", "diff"]) expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"]) expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"]) expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"]) expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
-18
View File
@@ -141,24 +141,6 @@ test("waits for a slow winner while bounding lock probes", async () => {
} }
}, 15_000) }, 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 () => { test("reports a contender that fails to start", async () => {
const directory = await temp() const directory = await temp()
const registration = join(directory, "service.json") const registration = join(directory, "service.json")
+5
View File
@@ -12,13 +12,18 @@
"@cloudflare/workers-types": "catalog:", "@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"@types/node": "catalog:", "@types/node": "catalog:",
"openai": "5.11.0",
"typescript": "catalog:", "typescript": "catalog:",
"@typescript/native-preview": "catalog:" "@typescript/native-preview": "catalog:"
}, },
"dependencies": { "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-core": "workspace:*",
"@opencode-ai/console-resource": "workspace:*", "@opencode-ai/console-resource": "workspace:*",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"ai": "catalog:",
"zod": "catalog:" "zod": "catalog:"
} }
} }
+4
View File
@@ -78,15 +78,18 @@
"@ai-sdk/cohere": "3.0.27", "@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104", "@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.51", "@ai-sdk/mistral": "3.0.51",
"@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26", "@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23", "@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/togetherai": "2.0.41", "@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39", "@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.102",
"@aws-sdk/credential-providers": "3.1057.0", "@aws-sdk/credential-providers": "3.1057.0",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:", "@effect/sql-sqlite-bun": "catalog:",
@@ -103,6 +106,7 @@
"@standard-schema/spec": "catalog:", "@standard-schema/spec": "catalog:",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4", "@silvia-odwyer/photon-node": "0.3.4",
"@openrouter/ai-sdk-provider": "2.9.0",
"ai-gateway-provider": "3.1.2", "ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8", "bun-pty": "0.4.8",
"diff": "catalog:", "diff": "catalog:",
+2 -20
View File
@@ -1,16 +1,11 @@
export * as Agent from "./agent" export * as Agent from "./agent"
import path from "path"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Array, Context, Effect, Layer, Types } from "effect" import { Array, Context, Effect, Layer, Types } from "effect"
import { Agent } from "@opencode-ai/schema/agent" import { Agent } from "@opencode-ai/schema/agent"
import { Global } from "@opencode-ai/util/global"
import { Bus } from "./bus" import { Bus } from "./bus"
import { State } from "./state" import { State } from "./state"
const SHELL_OUTPUT_GLOB = (data: string) => path.join(data, "shell", "*", "*")
const TOOL_OUTPUT_GLOB = (data: string) => path.join(data, "tool-output", "*")
export const ID = Agent.ID export const ID = Agent.ID
export type ID = typeof ID.Type export type ID = typeof ID.Type
export const Name = Agent.Name export const Name = Agent.Name
@@ -56,13 +51,6 @@ const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const global = yield* Global.Service
const permissions: Info["permissions"] = [
{ action: "external_directory", resource: SHELL_OUTPUT_GLOB(global.data), effect: "allow" },
{ action: "external_directory", resource: TOOL_OUTPUT_GLOB(global.data), effect: "allow" },
{ action: "external_directory", resource: path.join(global.tmp, "*"), effect: "allow" },
{ action: "external_directory", resource: path.join(global.config, "*"), effect: "allow" },
]
const state = State.create<Data, Draft>({ const state = State.create<Data, Draft>({
name: "agent", name: "agent",
initial: () => ({ agents: new Map() }), initial: () => ({ agents: new Map() }),
@@ -73,13 +61,7 @@ const layer = Layer.effect(
draft.default = id draft.default = id
}, },
update: (id, fn) => { update: (id, fn) => {
const defaults = Info.default(id) const current = draft.agents.get(id) ?? (Info.default(id) as Types.DeepMutable<Info>)
const current =
draft.agents.get(id) ??
({
...defaults,
permissions: [...defaults.permissions, ...permissions],
} as Types.DeepMutable<Info>)
if (!draft.agents.has(id)) draft.agents.set(id, current) if (!draft.agents.has(id)) draft.agents.set(id, current)
fn(current) fn(current)
current.id = id current.id = id
@@ -132,4 +114,4 @@ const layer = Layer.effect(
}), }),
) )
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node, Global.node] }) export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node] })
+1 -1
View File
@@ -413,7 +413,7 @@ function mapBodyToProviderOptions(model: Info, packageName: string) {
function callOptions(request: LLMRequest): LanguageModelV3CallOptions { function callOptions(request: LLMRequest): LanguageModelV3CallOptions {
return { return {
prompt: prompt(request), prompt: prompt(request),
maxOutputTokens: request.generation?.maxTokens, maxOutputTokens: request.generation?.maxTokens ?? request.model.route.defaults.limits?.output,
temperature: request.generation?.temperature, temperature: request.generation?.temperature,
stopSequences: request.generation?.stop === undefined ? undefined : [...request.generation.stop], stopSequences: request.generation?.stop === undefined ? undefined : [...request.generation.stop],
topP: request.generation?.topP, topP: request.generation?.topP,
@@ -71,14 +71,6 @@ const layer = Layer.effect(
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped) yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
} }
} }
if (location.vcs?.type === "hg") {
const store = location.vcs.store
const vcs = yield* fs.realPath(store).pipe(Effect.catch(() => Effect.succeed(store)))
if (!config.includes(".hg") && !config.includes(vcs)) {
const updates = yield* watcher.subscribe({ path: path.join(vcs, "branch"), type: "file" })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
}).pipe( }).pipe(
Effect.withSpan("LocationWatcher.start", { attributes: { directory: location.directory } }), Effect.withSpan("LocationWatcher.start", { attributes: { directory: location.directory } }),
Effect.catchCause((cause) => Effect.logError("failed to init location watcher service", { cause })), Effect.catchCause((cause) => Effect.logError("failed to init location watcher service", { cause })),
+1 -4
View File
@@ -2,7 +2,6 @@ export * as InstructionBuiltIns from "./builtins"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, DateTime, Effect, Layer, Schema } from "effect" import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../location" import { Location } from "../location"
import { SessionSchema } from "../session/schema" import { SessionSchema } from "../session/schema"
import { Instructions } from "./index" import { Instructions } from "./index"
@@ -16,7 +15,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/In
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const global = yield* Global.Service
const location = yield* Location.Service const location = yield* Location.Service
return Service.of({ return Service.of({
load: (sessionID) => load: (sessionID) =>
@@ -33,7 +31,6 @@ const layer = Layer.effect(
` Workspace root folder: ${location.project.directory}`, ` Workspace root folder: ${location.project.directory}`,
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`, ` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`, ` Platform: ${process.platform}`,
` Use ${global.tmp} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
"</env>", "</env>",
].join("\n"), ].join("\n"),
), ),
@@ -61,4 +58,4 @@ const layer = Layer.effect(
}), }),
) )
export const node = makeLocationNode({ service: Service, layer, deps: [Global.node, Location.node] }) export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] })
+52 -17
View File
@@ -1,10 +1,17 @@
export * as AgentPlugin from "./agent" export * as AgentPlugin from "./agent"
import path from "path"
import { define } from "@opencode-ai/plugin/effect/plugin" import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect" import { Effect } from "effect"
import { Agent } from "../agent" import { Agent } from "../agent"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../location"
import { Permission } from "../permission" import { Permission } from "../permission"
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
// Whitelisted so agents can read a command's full captured output without an external-directory prompt.
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases. const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
Your strengths: Your strengths:
@@ -93,12 +100,38 @@ Rules:
export const Plugin = define({ export const Plugin = define({
id: "opencode.agent", id: "opencode.agent",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service
const worktree = location.directory
const whitelistedDirs = [SHELL_OUTPUT_GLOB, path.join(Global.Path.tmp, "*")]
const readonlyExternalDirectory: Permission.Ruleset = [
{ action: "external_directory", resource: "*", effect: "ask" },
...whitelistedDirs.map(
(resource): Permission.Rule => ({ action: "external_directory", resource, effect: "allow" }),
),
]
const defaults: Permission.Ruleset = [
{ action: "*", resource: "*", effect: "allow" },
...readonlyExternalDirectory,
{ action: "question", resource: "*", effect: "deny" },
{ action: "plan_enter", resource: "*", effect: "deny" },
{ action: "plan_exit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "read", resource: "*.env", effect: "ask" },
{ action: "read", resource: "*.env.*", effect: "ask" },
{ action: "read", resource: "*.env.example", effect: "allow" },
]
yield* ctx.agent.transform((draft) => { yield* ctx.agent.transform((draft) => {
draft.update(Agent.defaultID, (item) => { draft.update(Agent.defaultID, (item) => {
item.name = Agent.Name.make("Build") item.name = Agent.Name.make("Build")
item.description = "The default agent. Executes tools based on configured permissions." item.description = "The default agent. Executes tools based on configured permissions."
item.mode = "primary" item.mode = "primary"
item.permissions.push({ action: "question", resource: "*", effect: "allow" }) item.permissions.push(
...Permission.merge(defaults, [
{ action: "question", resource: "*", effect: "allow" },
{ action: "plan_enter", resource: "*", effect: "allow" },
]),
)
}) })
draft.update(Agent.ID.make("plan"), (item) => { draft.update(Agent.ID.make("plan"), (item) => {
@@ -106,8 +139,18 @@ export const Plugin = define({
item.description = "Plan mode. Disallows all edit tools." item.description = "Plan mode. Disallows all edit tools."
item.mode = "primary" item.mode = "primary"
item.permissions.push( item.permissions.push(
{ action: "question", resource: "*", effect: "allow" }, ...Permission.merge(defaults, [
{ action: "edit", resource: "*", effect: "deny" }, { action: "question", resource: "*", effect: "allow" },
{ action: "plan_exit", resource: "*", effect: "allow" },
{ action: "external_directory", resource: path.join(Global.Path.data, "plans", "*"), effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "edit", resource: path.join(".opencode", "plans", "*.md"), effect: "allow" },
{
action: "edit",
resource: path.relative(worktree, path.join(Global.Path.data, "plans", "*.md")),
effect: "allow",
},
]),
) )
}) })
@@ -116,16 +159,10 @@ export const Plugin = define({
item.description = item.description =
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel." "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
item.mode = "subagent" item.mode = "subagent"
item.permissions.push( item.permissions.push(...Permission.merge(defaults, [{ action: "subagent", resource: "*", effect: "deny" }]))
{ action: "question", resource: "*", effect: "deny" },
{ action: "subagent", resource: "*", effect: "deny" },
)
}) })
draft.update(Agent.ID.make("explore"), (item) => { draft.update(Agent.ID.make("explore"), (item) => {
const externalDirectories = item.permissions.filter(
(rule) => rule.action === "external_directory" && rule.effect === "allow",
)
item.name = Agent.Name.make("Explore") item.name = Agent.Name.make("Explore")
item.description = item.description =
'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.' 'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.'
@@ -133,6 +170,7 @@ export const Plugin = define({
item.mode = "subagent" item.mode = "subagent"
item.permissions.push( item.permissions.push(
...Permission.merge( ...Permission.merge(
defaults,
[ [
{ action: "*", resource: "*", effect: "deny" }, { action: "*", resource: "*", effect: "deny" },
{ action: "grep", resource: "*", effect: "allow" }, { action: "grep", resource: "*", effect: "allow" },
@@ -140,12 +178,9 @@ export const Plugin = define({
{ action: "webfetch", resource: "*", effect: "allow" }, { action: "webfetch", resource: "*", effect: "allow" },
{ action: "websearch", resource: "*", effect: "allow" }, { action: "websearch", resource: "*", effect: "allow" },
{ action: "read", resource: "*", effect: "allow" }, { action: "read", resource: "*", effect: "allow" },
{ action: "read", resource: "*.env", effect: "ask" },
{ action: "read", resource: "*.env.*", effect: "ask" },
{ action: "read", resource: "*.env.example", effect: "allow" },
{ action: "subagent", resource: "*", effect: "deny" }, { action: "subagent", resource: "*", effect: "deny" },
], ],
[{ action: "external_directory", resource: "*", effect: "ask" }, ...externalDirectories], readonlyExternalDirectory,
), ),
) )
}) })
@@ -155,7 +190,7 @@ export const Plugin = define({
item.mode = "primary" item.mode = "primary"
item.hidden = true item.hidden = true
item.system = PROMPT_COMPACTION item.system = PROMPT_COMPACTION
item.permissions.push({ action: "*", resource: "*", effect: "deny" }) item.permissions.push(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
}) })
draft.update(Agent.ID.make("title"), (item) => { draft.update(Agent.ID.make("title"), (item) => {
@@ -163,7 +198,7 @@ export const Plugin = define({
item.mode = "primary" item.mode = "primary"
item.hidden = true item.hidden = true
item.system = PROMPT_TITLE item.system = PROMPT_TITLE
item.permissions.push({ action: "*", resource: "*", effect: "deny" }) item.permissions.push(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
}) })
draft.update(Agent.ID.make("summary"), (item) => { draft.update(Agent.ID.make("summary"), (item) => {
@@ -171,7 +206,7 @@ export const Plugin = define({
item.mode = "primary" item.mode = "primary"
item.hidden = true item.hidden = true
item.system = PROMPT_SUMMARY item.system = PROMPT_SUMMARY
item.permissions.push({ action: "*", resource: "*", effect: "deny" }) item.permissions.push(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
}) })
}) })
}), }),
+3 -61
View File
@@ -2,7 +2,6 @@ export * as PluginPromise from "./promise"
import { define } from "@opencode-ai/plugin/effect/plugin" import { define } from "@opencode-ai/plugin/effect/plugin"
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin" import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
import type { SessionHooks, SessionHttp, SessionHttpMiddleware } from "@opencode-ai/plugin/promise/session"
import type { Info } from "@opencode-ai/plugin/promise/tool" import type { Info } from "@opencode-ai/plugin/promise/tool"
import { Agent } from "@opencode-ai/schema/agent" import { Agent } from "@opencode-ai/schema/agent"
import { Integration } from "@opencode-ai/schema/integration" import { Integration } from "@opencode-ai/schema/integration"
@@ -58,62 +57,6 @@ export function fromPromise(plugin: Plugin) {
}), }),
) )
function sessionHook<Name extends keyof SessionHooks>(
name: Name,
callback: (event: SessionHooks[Name]) => Promise<void> | void,
): Promise<Registration>
function sessionHook(
...registration: {
[Name in keyof SessionHooks]: [
name: Name,
callback: (event: SessionHooks[Name]) => Promise<void> | void,
]
}[keyof SessionHooks]
) {
if (registration[0] !== "http")
return register(
host.session.hook(registration[0], (event) =>
Effect.promise(() => Promise.resolve(registration[1](event))),
),
)
return register(
host.session.hook("http", (event) => {
const middlewares: SessionHttpMiddleware[] = []
const output: SessionHttp = {
...event,
use: (item) => {
middlewares.push(item)
},
}
return Effect.promise(() => Promise.resolve(registration[1](output))).pipe(
Effect.flatMap(() =>
Effect.forEach(
middlewares,
(item) =>
event.use((input, next) =>
Effect.tryPromise({
try: (signal) => {
const inputSignal = AbortSignal.any([signal, input.signal])
return Promise.resolve(
item(new Request(input, { signal: inputSignal }), (request) => {
const requestSignal = AbortSignal.any([signal, request.signal])
return Effect.runPromiseWith(
context,
)(next(new Request(request, { signal: requestSignal })), { signal: requestSignal })
}),
)
},
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
}),
),
{ discard: true },
),
),
)
}),
)
}
const context2: Context = { const context2: Context = {
app: host.app, app: host.app,
options: host.options, options: host.options,
@@ -251,9 +194,7 @@ export function fromPromise(plugin: Plugin) {
), ),
), ),
refresh: refresh:
refresh === undefined refresh === undefined ? undefined : (credential) => Effect.promise(() => refresh(credential)),
? undefined
: (credential) => Effect.promise(() => refresh(credential)),
}) })
}, },
remove: draft.method.remove, remove: draft.method.remove,
@@ -322,7 +263,8 @@ export function fromPromise(plugin: Plugin) {
), ),
}, },
session: { session: {
hook: sessionHook, hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
create: (input) => create: (input) =>
run( run(
host.session.create( host.session.create(
+2
View File
@@ -11,6 +11,7 @@ import { DynamicProviderPlugin } from "./provider/dynamic"
import { GatewayPlugin } from "./provider/gateway" import { GatewayPlugin } from "./provider/gateway"
import { GithubCopilotPlugin } from "./provider/github-copilot" import { GithubCopilotPlugin } from "./provider/github-copilot"
import { GitLabPlugin } from "./provider/gitlab" import { GitLabPlugin } from "./provider/gitlab"
import { GooglePlugin } from "./provider/google"
import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "./provider/google-vertex" import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "./provider/google-vertex"
import { GroqPlugin } from "./provider/groq" import { GroqPlugin } from "./provider/groq"
import { KiloPlugin } from "./provider/kilo" import { KiloPlugin } from "./provider/kilo"
@@ -45,6 +46,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
GatewayPlugin, GatewayPlugin,
GithubCopilotPlugin, GithubCopilotPlugin,
GitLabPlugin, GitLabPlugin,
GooglePlugin,
GoogleVertexAnthropicPlugin, GoogleVertexAnthropicPlugin,
GoogleVertexPlugin, GoogleVertexPlugin,
GroqPlugin, GroqPlugin,
@@ -0,0 +1,16 @@
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)
}),
)
}),
})
+9 -8
View File
@@ -225,14 +225,15 @@ export const OpenAIPlugin = define({
}) })
} }
}) })
yield* ctx.session.hook("http", (evt) => yield* ctx.session.hook("request", (evt) =>
evt.use((request, next) => { Effect.sync(() => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return next(request) if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
const url = new URL(request.url) const url = new URL(evt.url)
request.headers.set("originator", "opencode") if (url.origin === "https://api.openai.com") {
request.headers.set("session-id", evt.sessionID) evt.url = `${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`
if (url.origin !== "https://api.openai.com") return next(request) }
return next(new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, request)) evt.headers.originator = "opencode"
evt.headers["session-id"] = evt.sessionID
}), }),
) )
@@ -23,5 +23,13 @@ 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,6 +6,7 @@ import { App } from "../../app"
import { Credential } from "../../credential" import { Credential } from "../../credential"
import { Integration } from "../../integration" import { Integration } from "../../integration"
import { OauthCallbackPage } from "../../oauth/page" import { OauthCallbackPage } from "../../oauth/page"
import { Provider } from "../../provider"
const clientID = "b1a00492-073a-47ea-816f-4c329264a828" const clientID = "b1a00492-073a-47ea-816f-4c329264a828"
const issuer = "https://auth.x.ai/oauth2" const issuer = "https://auth.x.ai/oauth2"
@@ -161,6 +162,21 @@ export const XAIPlugin = define({
draft.method.update(device(ctx.app)) draft.method.update(device(ctx.app))
draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } }) 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)
}),
)
}), }),
}) })
+7 -61
View File
@@ -2,7 +2,6 @@ export * as SessionCompaction from "./compaction"
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai" import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error" import { SessionError } from "@opencode-ai/schema/session-error"
import { Tool } from "@opencode-ai/schema/tool"
import { Context, Effect, Layer, Stream } from "effect" import { Context, Effect, Layer, Stream } from "effect"
import { Config } from "../config" import { Config } from "../config"
import { Bus } from "../bus" import { Bus } from "../bus"
@@ -20,10 +19,9 @@ import type { Info } from "../model"
import { SessionUsage } from "./usage" import { SessionUsage } from "./usage"
const DEFAULT_BUFFER = 20_000 const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 15_000 const DEFAULT_KEEP_TOKENS = 8_000
const OUTPUT_TOKEN_MAX = 32_000 const OUTPUT_TOKEN_MAX = 32_000
const TOOL_OUTPUT_MAX_CHARS = 2_000 const TOOL_OUTPUT_MAX_CHARS = 2_000
const MEDIA_TOKEN_ESTIMATE = 1_500
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response. const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
<template> <template>
## Objective ## Objective
@@ -92,7 +90,6 @@ type Plan = {
readonly reason: SessionMessage.Compaction["reason"] readonly reason: SessionMessage.Compaction["reason"]
readonly prompt: string readonly prompt: string
readonly recent: string readonly recent: string
readonly media: readonly Tool.FileContent[]
readonly inputID?: SessionMessage.ID readonly inputID?: SessionMessage.ID
} }
@@ -111,15 +108,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
const truncate = (value: string) => const truncate = (value: string) =>
value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]` value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]`
const isMedia = (mime: string) => {
const value = mime.toLowerCase()
return (
value.startsWith("image/") ||
value.startsWith("audio/") ||
value.startsWith("video/") ||
value === "application/pdf"
)
}
export const serializeToolContent = (content: SessionMessage.ToolStateCompleted["content"]) => export const serializeToolContent = (content: SessionMessage.ToolStateCompleted["content"]) =>
content content
.map((item) => .map((item) =>
@@ -127,24 +115,6 @@ export const serializeToolContent = (content: SessionMessage.ToolStateCompleted[
) )
.join("\n") .join("\n")
const isEstimatedMedia = (mime: string) =>
mime.toLowerCase().startsWith("image/") || mime.toLowerCase() === "application/pdf"
export const estimateMediaTokens = (message: SessionMessage.Info) => {
if (message.type === "user")
return (message.files?.filter((file) => isEstimatedMedia(file.mime)).length ?? 0) * MEDIA_TOKEN_ESTIMATE
if (message.type !== "assistant") return 0
return (
message.content
.flatMap((part) =>
part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")
? (part.state.content ?? [])
: [],
)
.filter((content) => content.type === "file" && isEstimatedMedia(content.mime)).length * MEDIA_TOKEN_ESTIMATE
)
}
const serialize = (message: SessionMessage.Info) => { const serialize = (message: SessionMessage.Info) => {
if (message.type === "user") { if (message.type === "user") {
const files = const files =
@@ -192,11 +162,7 @@ const settings = (documents: readonly Config.Entry[]) => {
const select = ( const select = (
messages: readonly SessionMessage.Info[], messages: readonly SessionMessage.Info[],
tokens: number, tokens: number,
): { ): { readonly head: string; readonly recent: string } | undefined => {
readonly head: string
readonly recent: string
readonly media: readonly Tool.FileContent[]
} | undefined => {
const conversation = messages const conversation = messages
.filter((message) => message.type !== "compaction" && message.type !== "system") .filter((message) => message.type !== "compaction" && message.type !== "system")
.flatMap((message) => { .flatMap((message) => {
@@ -207,7 +173,7 @@ const select = (
let total = 0 let total = 0
let split = conversation.length let split = conversation.length
for (let index = conversation.length - 1; index >= 0; index--) { for (let index = conversation.length - 1; index >= 0; index--) {
const next = total + Token.estimate(conversation[index].text) + estimateMediaTokens(conversation[index].message) const next = total + Token.estimate(conversation[index].text)
if (split < conversation.length && next > tokens) break if (split < conversation.length && next > tokens) break
total = next total = next
split = index split = index
@@ -217,33 +183,15 @@ const select = (
const latestUser = conversation.findLastIndex((item) => item.message.type === "user") const latestUser = conversation.findLastIndex((item) => item.message.type === "user")
if (latestUser > 0) split = latestUser if (latestUser > 0) split = latestUser
} }
const tail = conversation.slice(split)
return { return {
head: conversation head: conversation
.slice(0, split) .slice(0, split)
.map((item) => item.text) .map((item) => item.text)
.join("\n\n"), .join("\n\n"),
recent: tail.map((item) => item.text).join("\n\n"), recent: conversation
media: tail.flatMap((item) => { .slice(split)
if (item.message.type === "user") .map((item) => item.text)
return ( .join("\n\n"),
item.message.files
?.filter((file) => isMedia(file.mime))
.map((file) => ({
type: "file" as const,
uri: `data:${file.mime};base64,${file.data}`,
mime: file.mime,
name: file.name,
})) ?? []
)
if (item.message.type !== "assistant") return []
return item.message.content.flatMap((part) => {
if (part.type !== "tool" || (part.state.status !== "completed" && part.state.status !== "error")) return []
return (part.state.content ?? []).flatMap((content) =>
content.type === "file" && isMedia(content.mime) ? [content] : [],
)
})
}),
} }
} }
@@ -271,7 +219,6 @@ const planContent = (messages: readonly SessionMessage.Info[], tokens: number) =
context: summarizeRecent ? [selected.recent] : [previousRecent, selected.head].filter(Boolean), context: summarizeRecent ? [selected.recent] : [previousRecent, selected.head].filter(Boolean),
}), }),
recent: summarizeRecent ? "" : selected.recent, recent: summarizeRecent ? "" : selected.recent,
media: summarizeRecent ? [] : selected.media,
} }
} }
@@ -371,7 +318,6 @@ const make = (dependencies: Dependencies) => {
reason: plan.reason, reason: plan.reason,
text: summary, text: summary,
recent: plan.recent, recent: plan.recent,
media: plan.media,
}) })
return { status: "completed" as const } return { status: "completed" as const }
}) })
+8 -10
View File
@@ -111,9 +111,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
type DraftText = WritableDraft<SessionMessage.AssistantText> type DraftText = WritableDraft<SessionMessage.AssistantText>
type DraftReasoning = WritableDraft<SessionMessage.AssistantReasoning> type DraftReasoning = WritableDraft<SessionMessage.AssistantReasoning>
const latestTool = (assistant: DraftAssistant | undefined, id?: string) => const latestTool = (assistant: DraftAssistant | undefined, callID?: string) =>
assistant?.content.findLast( assistant?.content.findLast(
(item): item is DraftTool => item.type === "tool" && (id === undefined || item.id === id), (item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID),
) )
const latestText = (assistant: DraftAssistant | undefined) => const latestText = (assistant: DraftAssistant | undefined) =>
@@ -331,7 +331,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
castDraft( castDraft(
SessionMessage.AssistantTool.make({ SessionMessage.AssistantTool.make({
type: "tool", type: "tool",
id: event.data.id, id: event.data.callID,
name: event.data.name, name: event.data.name,
time: { created: event.created }, time: { created: event.created },
state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }), 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.delta": () => Effect.void,
"session.tool.input.ended": (event) => { "session.tool.input.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.id) const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "streaming") match.state.input = event.data.text if (match && match.state.status === "streaming") match.state.input = event.data.text
}) })
}, },
"session.tool.called": (event) => { "session.tool.called": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.id) const match = latestTool(draft, event.data.callID)
if (match) { if (match) {
match.executed = event.data.executed match.executed = event.data.executed
match.providerState = event.data.state match.providerState = event.data.state
@@ -366,7 +366,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}, },
"session.tool.progress": (event) => { "session.tool.progress": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.id) const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") { if (match && match.state.status === "running") {
match.state.metadata = event.data.metadata match.state.metadata = event.data.metadata
} }
@@ -376,7 +376,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
// never reaches into ephemeral progress history. // never reaches into ephemeral progress history.
"session.tool.success": (event) => { "session.tool.success": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.id) const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") { if (match && match.state.status === "running") {
match.executed = event.data.executed || match.executed === true match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState match.providerResultState = event.data.resultState
@@ -394,7 +394,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}, },
"session.tool.failed": (event) => { "session.tool.failed": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.id) const match = latestTool(draft, event.data.callID)
if (match && (match.state.status === "streaming" || match.state.status === "running")) { if (match && (match.state.status === "streaming" || match.state.status === "running")) {
match.executed = event.data.executed || match.executed === true match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState match.providerResultState = event.data.resultState
@@ -480,7 +480,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
reason: event.data.reason, reason: event.data.reason,
summary: event.data.text, summary: event.data.text,
recent: event.data.recent, recent: event.data.recent,
media: event.data.media,
}) })
return return
} }
@@ -493,7 +492,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
reason: event.data.reason, reason: event.data.reason,
summary: event.data.text, summary: event.data.text,
recent: event.data.recent, recent: event.data.recent,
media: event.data.media,
time: { created: event.created }, time: { created: event.created },
}), }),
) )
+40 -73
View File
@@ -2,11 +2,9 @@ export * as SessionModelRequest from "./model-request"
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai" import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route" import type { StreamOptions } from "@opencode-ai/ai/route"
import type { SessionHttpHandler, SessionHttpMiddleware } from "@opencode-ai/plugin/effect/session"
import type { Content } from "@opencode-ai/schema/tool" import type { Content } from "@opencode-ai/schema/tool"
import { SessionError } from "@opencode-ai/schema/session-error" import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect" import { Cause, Config, Context, Effect, Layer, Result } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app" import { App } from "../app"
import { Model } from "../model" import { Model } from "../model"
@@ -50,7 +48,9 @@ interface Prepared {
* One request-scoped execution operation. Unknown, hook-removed, and * One request-scoped execution operation. Unknown, hook-removed, and
* step-limit-violating calls fail individually through the same seam. * step-limit-violating calls fail individually through the same seam.
*/ */
readonly executeTool: (input: Parameters<Tool.Snapshot["execute"]>[0]) => Effect.Effect<Tool.Result, ExecuteError> readonly executeTool: (
input: Parameters<Tool.Snapshot["execute"]>[0],
) => Effect.Effect<Tool.Result, ExecuteError>
/** True when this request is the final Step; violating calls are rejected and no continuation follows. */ /** True when this request is the final Step; violating calls are rejected and no continuation follows. */
readonly stepLimitReached: boolean readonly stepLimitReached: boolean
} }
@@ -137,7 +137,8 @@ export const boundImages = (messages: LLMRequest["messages"]) => {
result: { result: {
...part.result, ...part.result,
value: part.result.value.map((item: Content) => { value: part.result.value.map((item: Content) => {
if (item.type !== "file" || !isImage(item.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET) return item if (item.type !== "file" || !isImage(item.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET)
return item
removed += Buffer.byteLength(item.uri) removed += Buffer.byteLength(item.uri)
return { type: "text" as const, text: IMAGE_REMOVED } return { type: "text" as const, text: IMAGE_REMOVED }
}), }),
@@ -188,87 +189,55 @@ export const layer = Layer.effect(
.map(SystemPart.make) .map(SystemPart.make)
const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey) const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey)
const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history
const registry = new Map(tools.definitions.map((tool) => [tool.name, tool])) const toolDefinitions = tools.definitions
// The definition objects we hand to hooks, mapped back to their tools. Hooks rename a const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
// tool by moving its definition to a new key; recognizing the object recovers the tool. // Hooks may reshape available definitions but cannot advertise tools omitted by permissions or the Step limit.
const given = new Map( const contextEvent = yield* hooks.trigger("session", "context", {
tools.definitions.map(
(tool) => [{ description: tool.description, input: { ...tool.inputSchema } }, tool] as const,
),
)
// Hooks mutate this record in place: edit descriptions and schemas, rename, or remove.
const context = yield* hooks.trigger("session", "context", {
sessionID: session.id, sessionID: session.id,
agent: agent.id, agent: agent.id,
model: resolved.ref, model: resolved.ref,
system, system,
messages, messages,
tools: Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition])), tools: Object.fromEntries(
toolDefinitions.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
),
})
const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
const registered = toolsByName.get(name)
return registered
? [{ ...registered, description: tool.description, inputSchema: tool.input }]
: []
}) })
// Match each surviving entry back to its tool, by recognizing a moved definition or
// by key. Identity wins so a definition moved onto another tool's name still executes
// the tool it describes. Entries matching neither were invented by a hook and dropped.
// `tool.name` stays canonical so execution can translate renamed calls back.
const hooked = new Map(
Object.entries(context.tools).flatMap(([name, definition]) => {
const tool = given.get(definition) ?? registry.get(name)
if (!tool) return []
return [[name, { ...tool, description: definition.description, inputSchema: definition.input }] as const]
}),
)
const request = LLM.request({ const request = LLM.request({
model, model,
http: { http: {
headers: SessionModelHeaders.make(session, app), headers: SessionModelHeaders.make(session, app),
}, },
providerOptions: { [providerMetadataKey]: { promptCacheKey } }, providerOptions: { [providerMetadataKey]: { promptCacheKey } },
system: context.system, system: contextEvent.system,
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)), messages: boundImages(unsupportedParts(contextEvent.messages, resolved.capabilities)),
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })), tools: hookedTools,
toolChoice: stepLimitReached ? "none" : undefined, toolChoice: stepLimitReached ? "none" : undefined,
}) })
const options: StreamOptions = { const options: StreamOptions = {
http: (request, handler) => transform: (request) =>
Effect.gen(function* () { hooks
let latest = request .trigger("session", "request", {
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
const middlewares: SessionHttpMiddleware[] = []
const web = yield* HttpClientRequest.toWeb(request)
yield* hooks.trigger("session", "http", {
sessionID: session.id, sessionID: session.id,
agent: agent.id, agent: agent.id,
model: resolved.ref, model: resolved.ref,
use: (item) => ...request,
Effect.sync(() => {
middlewares.push(item)
}),
}) })
const send = (input: Request) => .pipe(
Effect.gen(function* () { Effect.tap((event) =>
let sent = HttpClientRequest.fromWeb(input) Effect.sync(() => {
if (input.body) request.url = event.url
sent = HttpClientRequest.bodyUint8Array( request.headers = event.headers
sent, request.body = event.body
new Uint8Array(yield* Effect.promise(() => input.clone().arrayBuffer())), }),
input.headers.get("content-type") ?? undefined, ),
) Effect.asVoid,
latest = sent ),
const response = yield* handler(sent)
const body = [204, 205, 304].includes(response.status)
? null
: yield* Stream.toReadableStreamEffect(response.stream)
const output = new Response(body, { status: response.status, headers: response.headers })
origins.set(output, sent)
return output
})
const dispatch = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
send,
)
const response = yield* dispatch(web)
const origin = origins.get(response) ?? latest
return HttpClientResponse.fromWeb(origin, response)
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
} }
if (promptCacheSnapshots) { if (promptCacheSnapshots) {
const current = PromptCacheDiagnostics.snapshot(request) const current = PromptCacheDiagnostics.snapshot(request)
@@ -287,15 +256,13 @@ export const layer = Layer.effect(
}), }),
) )
} }
const executeTool: Prepared["executeTool"] = (input) => { const executeTool: Prepared["executeTool"] = (executeInput) => {
if (stepLimitReached) if (stepLimitReached)
return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" }) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
const tool = hooked.get(input.call.name) if (toolsByName.has(executeInput.call.name) && !Object.hasOwn(contextEvent.tools, executeInput.call.name))
// A registered tool absent from the hooked set was removed or renamed by a hook. return new Tool.Error({ message: `Tool is not available for this request: ${executeInput.call.name}` })
if (!tool && registry.has(input.call.name))
return new Tool.Error({ message: `Tool is not available for this request: ${input.call.name}` })
return tools return tools
.execute(tool ? { ...input, call: { ...input.call, name: tool.name } } : input) .execute(executeInput)
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline))) .pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
} }
return { return {
+1 -1
View File
@@ -488,7 +488,7 @@ const layer = Layer.effect(
yield* bus.publish(SessionEvent.Tool.Failed, { yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID, sessionID,
assistantMessageID: message.id, assistantMessageID: message.id,
id: tool.id, callID: tool.id,
error: { type: "aborted", message: `Tool execution interrupted: ${tool.name}` }, error: { type: "aborted", message: `Tool execution interrupted: ${tool.name}` },
executed: tool.executed === true, 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 * 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 * 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, * order: each publishing fiber is sequential, so per-source order holds by construction,
* and consumers fold by id/ordinal rather than global position. * and consumers fold by callID/ordinal rather than global position.
*/ */
export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, input: Input) => { export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, input: Input) => {
const tools = new Map< const tools = new Map<
@@ -188,14 +188,14 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
}), }),
true, true,
) )
const toolInput = fragments("tool input", (id, value) => const toolInput = fragments("tool input", (callID, value) =>
Effect.gen(function* () { Effect.gen(function* () {
const tool = tools.get(id) const tool = tools.get(callID)
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${id}`)) if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${callID}`))
yield* bus.publish(SessionEvent.Tool.Input.Ended, { yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID, assistantMessageID: tool.assistantMessageID,
id, callID,
text: value, text: value,
}) })
}), }),
@@ -225,7 +225,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Input.Started, { yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID, assistantMessageID,
id: event.id, callID: event.id,
name: event.name, name: event.name,
}) })
}) })
@@ -258,7 +258,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Failed, { yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID, assistantMessageID: tool.assistantMessageID,
id: event.id, callID: event.id,
error: { error: {
type: "tool.input-json", type: "tool.input-json",
message: "Tool call arguments were malformed JSON and were not executed. Retry with valid 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() yield* flushFragments()
}) })
const failTool = Effect.fnUntraced(function* (id: string, error: SessionError.Error) { const failTool = Effect.fnUntraced(function* (callID: string, error: SessionError.Error) {
const tool = tools.get(id) const tool = tools.get(callID)
if (!tool || tool.settled) return false if (!tool || tool.settled) return false
tool.settled = true tool.settled = true
yield* bus.publish(SessionEvent.Tool.Failed, { yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID, assistantMessageID: tool.assistantMessageID,
id, callID,
error, error,
...failureSnapshot(tool), ...failureSnapshot(tool),
executed: tool.providerExecuted, 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") { const failTools = Effect.fnUntraced(function* (error: SessionError.Error, mode: "all" | "hosted" | "uncalled") {
let failed = false let failed = false
for (const [id, tool] of tools) { for (const [callID, tool] of tools) {
if (tool.settled || (mode === "hosted" && !tool.providerExecuted) || (mode === "uncalled" && tool.called)) if (tool.settled || (mode === "hosted" && !tool.providerExecuted) || (mode === "uncalled" && tool.called))
continue continue
failed = (yield* failTool(id, error)) || failed failed = (yield* failTool(callID, error)) || failed
} }
return failed return failed
}) })
@@ -328,9 +328,9 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
return yield* failTools(error, scope) return yield* failTools(error, scope)
}) })
const assistantMessageIDForTool = (id: string) => { const assistantMessageIDForTool = (callID: string) => {
const tool = tools.get(id) const tool = tools.get(callID)
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${id}`)) return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`))
} }
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent) { 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, { yield* bus.publish(SessionEvent.Tool.Input.Delta, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID, assistantMessageID: tool.assistantMessageID,
id: event.id, callID: event.id,
delta: event.text, delta: event.text,
}) })
return return
@@ -424,7 +424,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Called, { yield* bus.publish(SessionEvent.Tool.Called, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID, assistantMessageID: tool.assistantMessageID,
id: event.id, callID: event.id,
input: asRecord(event.input), input: asRecord(event.input),
executed: tool.providerExecuted, executed: tool.providerExecuted,
state: providerState(event.providerMetadata), state: providerState(event.providerMetadata),
@@ -450,7 +450,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Failed, { yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID, assistantMessageID: tool.assistantMessageID,
id: event.id, callID: event.id,
error: { type: "tool.execution", message: stringify(event.result.value) }, error: { type: "tool.execution", message: stringify(event.result.value) },
...failureSnapshot(tool), ...failureSnapshot(tool),
executed, executed,
@@ -461,7 +461,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Success, { yield* bus.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID, assistantMessageID: tool.assistantMessageID,
id: event.id, callID: event.id,
content: hostedContent(event.result), content: hostedContent(event.result),
executed, executed,
resultState, resultState,
@@ -478,7 +478,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Failed, { yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID, assistantMessageID: tool.assistantMessageID,
id: event.id, callID: event.id,
error: error:
event.message === `Unknown tool: ${event.name}` event.message === `Unknown tool: ${event.name}`
? { type: "tool.unknown", message: event.message } ? { type: "tool.unknown", message: event.message }
@@ -508,30 +508,30 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
} }
}) })
const progress = Effect.fnUntraced(function* (id: string, update: Tool.Metadata) { const progress = Effect.fnUntraced(function* (callID: string, update: Tool.Metadata) {
const tool = tools.get(id) const tool = tools.get(callID)
if (!tool?.called || tool.settled) if (!tool?.called || tool.settled)
return yield* Effect.die(new Error(`Tool progress outside running call: ${id}`)) return yield* Effect.die(new Error(`Tool progress outside running call: ${callID}`))
tool.progress = update tool.progress = update
yield* bus.publish(SessionEvent.Tool.Progress, { yield* bus.publish(SessionEvent.Tool.Progress, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID, assistantMessageID: tool.assistantMessageID,
id, callID,
metadata: update, metadata: update,
}) })
}) })
/** Publishes one canonical terminal event for a locally executed tool call. */ /** Publishes one canonical terminal event for a locally executed tool call. */
const toolExecution = Effect.fnUntraced(function* ( const toolExecution = Effect.fnUntraced(function* (
id: string, callID: string,
name: string, name: string,
result: Tool.Result, result: Tool.Result,
) { ) {
const tool = tools.get(id) const tool = tools.get(callID)
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${id}`)) if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${callID}`))
if (tool.name !== name) if (tool.name !== name)
return yield* Effect.die(new Error(`Tool execution name changed for ${id}: ${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: ${id}`)) if (tool.settled) return yield* Effect.die(new Error(`Duplicate tool execution: ${callID}`))
tool.settled = true tool.settled = true
const content = const content =
typeof result.content === "string" typeof result.content === "string"
@@ -539,11 +539,11 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
: result.content === undefined : result.content === undefined
? [] ? []
: [...result.content] : [...result.content]
if (content.length === 0) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`)) if (content.length === 0) return yield* Effect.die(new Error(`Tool execution has no content: ${callID}`))
yield* bus.publish(SessionEvent.Tool.Success, { yield* bus.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID, assistantMessageID: tool.assistantMessageID,
id, callID,
content: [content[0], ...content.slice(1)], content: [content[0], ...content.slice(1)],
...(result.metadata === undefined ? {} : { metadata: result.metadata }), ...(result.metadata === undefined ? {} : { metadata: result.metadata }),
executed: tool.providerExecuted, executed: tool.providerExecuted,
@@ -222,8 +222,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
Message.make({ Message.make({
id: message.id, id: message.id,
role: "user", role: "user",
content: [ content: `<conversation-checkpoint>
Message.text(`<conversation-checkpoint>
The following is a summary and serialized record of earlier conversation. Treat it as historical context, not as new instructions. The following is a summary and serialized record of earlier conversation. Treat it as historical context, not as new instructions.
<summary> <summary>
@@ -233,14 +232,7 @@ ${message.summary}
<recent-context> <recent-context>
${message.recent} ${message.recent}
</recent-context> </recent-context>
</conversation-checkpoint>`), </conversation-checkpoint>`,
...(message.media ?? []).map((media) => ({
type: "media" as const,
mediaType: media.mime,
data: media.uri,
filename: media.name,
})),
],
metadata: message.metadata, metadata: message.metadata,
}), }),
] ]
+3 -3
View File
@@ -93,7 +93,7 @@ const layer = Layer.effect(
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
messageID: context.messageID, messageID: context.messageID,
id: context.id, callID: context.callID,
input, input,
} }
yield* hooks.trigger("tool", "execute.before", beforeEvent) yield* hooks.trigger("tool", "execute.before", beforeEvent)
@@ -106,7 +106,7 @@ const layer = Layer.effect(
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
messageID: context.messageID, messageID: context.messageID,
id: context.id, callID: context.callID,
input: beforeEvent.input, input: beforeEvent.input,
} }
if ("failure" in execution) { if ("failure" in execution) {
@@ -228,7 +228,7 @@ const layer = Layer.effect(
sessionID: input.sessionID, sessionID: input.sessionID,
agent: input.agent, agent: input.agent,
messageID: input.messageID, messageID: input.messageID,
id: Tool.CallID.make(input.call.id), callID: Tool.CallID.make(input.call.id),
progress: input.progress ?? (() => Effect.void), progress: input.progress ?? (() => Effect.void),
} }
if (input.call.name === "execute" && codemodeTool) 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 = { const source = {
type: "tool" as const, type: "tool" as const,
messageID: context.messageID, messageID: context.messageID,
id: context.id, callID: context.callID,
} }
``` ```
+1 -1
View File
@@ -59,7 +59,7 @@ export const layer = Layer.effectDiscard(
source: { source: {
type: "tool", type: "tool",
messageID: context.messageID, messageID: context.messageID,
id: context.id, callID: context.callID,
}, },
}) })
const result = yield* mcp const result = yield* mcp
+1 -1
View File
@@ -129,7 +129,7 @@ export const Plugin = {
const permissionSource = { const permissionSource = {
type: "tool" as const, type: "tool" as const,
messageID: context.messageID, messageID: context.messageID,
id: context.id, callID: context.callID,
} }
if (input.oldString === input.newString) { if (input.oldString === input.newString) {
return yield* new ToolFailure({ return yield* new ToolFailure({
+1 -1
View File
@@ -61,7 +61,7 @@ export const Plugin = {
execute: (input, context) => execute: (input, context) =>
Effect.gen(function* () { Effect.gen(function* () {
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
const source = { type: "tool" as const, messageID: context.messageID, id: context.id } const source = { type: "tool" as const, messageID: context.messageID, callID: context.callID }
const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" }) const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" })
const external = target.externalDirectory const external = target.externalDirectory
if (external) if (external)
+1 -1
View File
@@ -76,7 +76,7 @@ export const Plugin = {
output: Output, output: Output,
execute: (input, context) => execute: (input, context) =>
Effect.gen(function* () { Effect.gen(function* () {
const source = { type: "tool" as const, messageID: context.messageID, id: context.id } const source = { type: "tool" as const, messageID: context.messageID, callID: context.callID }
const target = yield* mutation.resolve({ path: input.path ?? "." }) const target = yield* mutation.resolve({ path: input.path ?? "." })
if (target.externalDirectory) if (target.externalDirectory)
yield* permission.assert({ yield* permission.assert({
+1 -1
View File
@@ -95,7 +95,7 @@ export const Plugin = {
const source = { const source = {
type: "tool" as const, type: "tool" as const,
messageID: context.messageID, messageID: context.messageID,
id: context.id, callID: context.callID,
} }
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" }) if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe( const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
+2 -2
View File
@@ -70,7 +70,7 @@ export const Plugin = {
resources: ["*"], resources: ["*"],
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id }, source: { type: "tool", messageID: context.messageID, callID: context.callID },
}) })
.pipe( .pipe(
Effect.mapError((error) => new ToolFailure({ message: "Permission denied: question", error })), Effect.mapError((error) => new ToolFailure({ message: "Permission denied: question", error })),
@@ -81,7 +81,7 @@ export const Plugin = {
title: "Questions", title: "Questions",
metadata: { metadata: {
kind: "question", kind: "question",
tool: { messageID: context.messageID, id: context.id }, tool: { messageID: context.messageID, callID: context.callID },
}, },
fields: [ fields: [
toField(input.questions[0], 0), toField(input.questions[0], 0),
+1 -1
View File
@@ -56,7 +56,7 @@ export const Plugin = {
const source = { const source = {
type: "tool" as const, type: "tool" as const,
messageID: context.messageID, messageID: context.messageID,
id: context.id, callID: context.callID,
} }
const target = yield* mutation.resolve({ path: input.path, kind: "directory" }) const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
const external = target.externalDirectory const external = target.externalDirectory
+7 -7
View File
@@ -89,10 +89,10 @@ export const Plugin = {
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* ( const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
id: string, callID: string,
command: string, command: string,
) { ) {
yield* runtime.job.wait({ id: id }).pipe( yield* runtime.job.wait({ id: callID }).pipe(
Effect.flatMap((result) => { Effect.flatMap((result) => {
const state = const state =
result.info?.status === "completed" result.info?.status === "completed"
@@ -111,7 +111,7 @@ export const Plugin = {
: "Command cancelled" : "Command cancelled"
return runtime.session.synthetic({ return runtime.session.synthetic({
sessionID, sessionID,
text: `<shell id="${id}" state="${state}" command="${command}">\n${text}\n</shell>`, text: `<shell id="${callID}" state="${state}" command="${command}">\n${text}\n</shell>`,
description: command, description: command,
metadata: { source: "shell", state }, metadata: { source: "shell", state },
}) })
@@ -134,7 +134,7 @@ export const Plugin = {
const source = { const source = {
type: "tool" as const, type: "tool" as const,
messageID: context.messageID, messageID: context.messageID,
id: context.id, callID: context.callID,
} }
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS) const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
let finalTimeout = timeout let finalTimeout = timeout
@@ -227,7 +227,7 @@ export const Plugin = {
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)), Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
) )
const job = yield* runtime.job.start({ const job = yield* runtime.job.start({
id: context.id, id: context.callID,
type: name, type: name,
title: info.command, title: info.command,
metadata: { sessionID: context.sessionID, shellID: info.id }, metadata: { sessionID: context.sessionID, shellID: info.id },
@@ -236,7 +236,7 @@ export const Plugin = {
if (input.background === true) { if (input.background === true) {
yield* runtime.job.background(job.id) yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.id, info.command) yield* notifyWhenDone(context.sessionID, context.callID, info.command)
return { return {
output: BACKGROUND_STARTED, output: BACKGROUND_STARTED,
shellID: info.id, shellID: info.id,
@@ -250,7 +250,7 @@ export const Plugin = {
) )
if (result?.type === "backgrounded") { if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0) yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.id, info.command) yield* notifyWhenDone(context.sessionID, context.callID, info.command)
return { return {
output: BACKGROUND_STARTED, output: BACKGROUND_STARTED,
shellID: info.id, shellID: info.id,
+1 -1
View File
@@ -75,7 +75,7 @@ export const Plugin = {
save: [skill.id], save: [skill.id],
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id }, source: { type: "tool", messageID: context.messageID, callID: context.callID },
}) })
const directory = path.dirname(skill.location) const directory = path.dirname(skill.location)
const files = const files =
+1 -1
View File
@@ -159,7 +159,7 @@ export const Plugin = {
source: { source: {
type: "tool", type: "tool",
messageID: context.messageID, messageID: context.messageID,
id: context.id, callID: context.callID,
}, },
}) })
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error }))) .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, metadata: input,
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id }, source: { type: "tool", messageID: context.messageID, callID: context.callID },
}) })
const { body, contentType } = yield* Effect.gen(function* () { const { body, contentType } = yield* Effect.gen(function* () {
+1 -1
View File
@@ -47,7 +47,7 @@ export const Plugin = {
metadata: input, metadata: input,
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id }, source: { type: "tool", messageID: context.messageID, callID: context.callID },
}) })
const result = yield* ctx.websearch.query(input).pipe( const result = yield* ctx.websearch.query(input).pipe(
Effect.catch((error) => { Effect.catch((error) => {
+1 -1
View File
@@ -67,7 +67,7 @@ export const Plugin = {
const source = { const source = {
type: "tool" as const, type: "tool" as const,
messageID: context.messageID, messageID: context.messageID,
id: context.id, callID: context.callID,
} }
const target = yield* mutation.resolve({ path: input.path, kind: "file" }) const target = yield* mutation.resolve({ path: input.path, kind: "file" })
const external = target.externalDirectory const external = target.externalDirectory
+4 -37
View File
@@ -1,27 +1,22 @@
export * as Vcs from "./vcs" export * as Vcs from "./vcs"
import path from "path" import { Context, Effect, Layer } from "effect"
import { Context, Effect, Layer, Stream } from "effect"
import { FileDiff } from "@opencode-ai/schema/file-diff" import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileSystem } from "@opencode-ai/schema/filesystem" import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util" import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location" import { Location } from "./location"
import { AppProcess } from "@opencode-ai/util/process" import { AppProcess } from "@opencode-ai/util/process"
import { Bus } from "./bus"
import { VcsGit } from "./vcs/git" import { VcsGit } from "./vcs/git"
import { VcsHg } from "./vcs/hg" import { VcsHg } from "./vcs/hg"
export { FileStatus, Info, Mode } export { FileStatus, Mode }
export interface DiffOptions { export interface DiffOptions {
readonly context?: number readonly context?: number
} }
export interface Interface { export interface Interface {
readonly info: () => Effect.Effect<Info>
readonly status: () => Effect.Effect<FileStatus[]> readonly status: () => Effect.Effect<FileStatus[]>
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff.Info[]> readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff.Info[]>
} }
@@ -43,36 +38,8 @@ const layer = Layer.effect(
const proc = yield* AppProcess.Service const proc = yield* AppProcess.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const location = yield* Location.Service const location = yield* Location.Service
const bus = yield* Bus.Service
const impl = adapter(proc, fs, location) const impl = adapter(proc, fs, location)
const vcs = location.vcs
const state = { info: impl ? yield* impl.info() : { branch: {} } satisfies Info }
if (vcs && impl) {
const store = yield* fs.realPath(vcs.store).pipe(Effect.catch(() => Effect.succeed(vcs.store)))
const isBranchMetadata =
vcs.type === "git"
? (file: string) => path.basename(file) === "HEAD" && FSUtil.contains(store, file)
: (file: string) => path.resolve(file) === path.join(store, "branch")
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
Stream.filter((event) => isBranchMetadata(event.data.file)),
Stream.runForEach((event) =>
Effect.gen(function* () {
const next = yield* impl.info()
const changed = state.info.branch.current !== next.branch.current
state.info = next
if (!changed) return
yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
}).pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
),
Effect.forkScoped({ startImmediately: true }),
)
}
return Service.of({ return Service.of({
info: Effect.fn("Vcs.info")(function* () {
return state.info
}),
status: Effect.fn("Vcs.status")(function* () { status: Effect.fn("Vcs.status")(function* () {
if (!impl) return [] if (!impl) return []
return yield* impl.status() return yield* impl.status()
@@ -88,5 +55,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({ export const node = makeLocationNode({
service: Service, service: Service,
layer: layer, layer: layer,
deps: [AppProcess.node, FSUtil.node, Location.node, Bus.node], deps: [AppProcess.node, FSUtil.node, Location.node],
}) })
+1 -7
View File
@@ -3,7 +3,7 @@ export * as VcsGit from "./git"
import { Effect } from "effect" import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import { FileDiff } from "@opencode-ai/schema/file-diff" import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs" import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
import { AppProcess } from "@opencode-ai/util/process" import { AppProcess } from "@opencode-ai/util/process"
import type { DiffOptions, Interface } from "../vcs" import type { DiffOptions, Interface } from "../vcs"
import { chunksByFile, emptyPatch, MAX_PATCH_BYTES, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./patch" import { chunksByFile, emptyPatch, MAX_PATCH_BYTES, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./patch"
@@ -20,12 +20,6 @@ export function make(proc: AppProcess.Interface, input: { directory: string; wor
const ctx: Ctx = { git: makeGit(proc), directory: input.directory, worktree: input.worktree } const ctx: Ctx = { git: makeGit(proc), directory: input.directory, worktree: input.worktree }
return { return {
info: Effect.fn("VcsGit.info")(function* () {
const [current, root] = yield* Effect.all([ctx.git.branch(ctx.directory), ctx.git.defaultBranch(ctx.directory)], {
concurrency: 2,
})
return { branch: { current, default: root?.name } } satisfies Info
}),
status: Effect.fn("VcsGit.status")(function* () { status: Effect.fn("VcsGit.status")(function* () {
const git = ctx.git const git = ctx.git
const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined
+1 -4
View File
@@ -4,7 +4,7 @@ import path from "path"
import { Effect } from "effect" import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import { FileDiff } from "@opencode-ai/schema/file-diff" import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs" import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
import { FSUtil } from "@opencode-ai/util/fs-util" import { FSUtil } from "@opencode-ai/util/fs-util"
import { AppProcess } from "@opencode-ai/util/process" import { AppProcess } from "@opencode-ai/util/process"
import type { DiffOptions, Interface } from "../vcs" import type { DiffOptions, Interface } from "../vcs"
@@ -73,9 +73,6 @@ export function make(
}) })
return { return {
info: Effect.fn("VcsHg.info")(function* () {
return { branch: { current: yield* hg.branch(), default: "default" } } satisfies Info
}),
status: Effect.fn("VcsHg.status")(function* () { status: Effect.fn("VcsHg.status")(function* () {
const [items, batch] = yield* Effect.all( const [items, batch] = yield* Effect.all(
// Zero-context patches are enough to count changed lines. // Zero-context patches are enough to count changed lines.
+12 -36
View File
@@ -1,4 +1,3 @@
import path from "path"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Layer, Scope, Stream } from "effect" import { Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import { TestClock } from "effect/testing" import { TestClock } from "effect/testing"
@@ -10,19 +9,15 @@ import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission" import { Permission } from "@opencode-ai/core/permission"
import { AgentPlugin } from "@opencode-ai/core/plugin/agent" import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/util/global"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { agentHost, host } from "./plugin/host" import { agentHost, host } from "./plugin/host"
const testLocation = location({ directory: AbsolutePath.make("/project") }) const testLocation = location({ directory: AbsolutePath.make("/project") })
const locationLayer = Layer.succeed(Location.Service, Location.Service.of(testLocation)) const locationLayer = Layer.succeed(Location.Service, Location.Service.of(testLocation))
const global = Global.make({ data: "/data", config: "/config", tmp: "/tmp/opencode" })
const globalLayer = Layer.succeed(Global.Service, Global.Service.of(global))
const it = testEffect( const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, Location.node]), [ AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, Location.node]), [
[Global.node, globalLayer],
[Location.node, locationLayer], [Location.node, locationLayer],
]) as unknown as Layer.Layer<unknown, never>, ]) as unknown as Layer.Layer<unknown, never>,
) )
@@ -125,35 +120,25 @@ describe("Agent", () => {
const id = Agent.ID.make("custom") const id = Agent.ID.make("custom")
yield* agent.transform((editor) => editor.update(id, () => {})) yield* agent.transform((editor) => editor.update(id, () => {}))
const info = yield* agent.get(id) expect(yield* agent.get(id)).toEqual(Agent.Info.default(id))
expect(info?.permissions.slice(0, Agent.Info.default(id).permissions.length)).toEqual(
Agent.Info.default(id).permissions,
)
expect(Permission.evaluate("external_directory", path.join(global.data, "shell", "*", "*"), info?.permissions ?? []).effect).toBe(
"allow",
)
expect(Permission.evaluate("external_directory", path.join(global.data, "tool-output", "*"), info?.permissions ?? []).effect).toBe(
"allow",
)
expect(Permission.evaluate("external_directory", path.join(global.config, "*"), info?.permissions ?? []).effect).toBe(
"allow",
)
expect(Permission.evaluate("external_directory", path.join(global.tmp, "*"), info?.permissions ?? []).effect).toBe(
"allow",
)
yield* agent.transform((editor) => editor.remove(id)) yield* agent.transform((editor) => editor.remove(id))
expect(yield* agent.get(id)).toBeUndefined() expect(yield* agent.get(id)).toBeUndefined()
}), }),
) )
it.effect("applies managed external directories without opting built-in agents into bash", () => it.effect("does not ambiently opt built-in agents into bash", () =>
Effect.gen(function* () { Effect.gen(function* () {
const agent = yield* Agent.Service const agent = yield* Agent.Service
yield* AgentPlugin.Plugin.effect( yield* AgentPlugin.Plugin.effect(
host({ host({
agent: agentHost(agent), agent: agentHost(agent),
}), }),
).pipe(
Effect.provideService(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
),
) )
const agents = yield* agent.list() const agents = yield* agent.list()
@@ -167,20 +152,6 @@ describe("Agent", () => {
"title", "title",
]) ])
expect((yield* agent.get(Agent.defaultID))?.system).toBeUndefined() expect((yield* agent.get(Agent.defaultID))?.system).toBeUndefined()
const permissions = (yield* agent.get(Agent.defaultID))?.permissions ?? []
expect(
Permission.evaluate("external_directory", path.join(global.data, "shell", "*", "*"), permissions).effect,
).toBe("allow")
expect(
Permission.evaluate("external_directory", path.join(global.data, "tool-output", "*"), permissions).effect,
).toBe("allow")
expect(Permission.evaluate("external_directory", path.join(global.config, "*"), permissions).effect).toBe("allow")
expect(Permission.evaluate("external_directory", path.join(global.tmp, "*"), permissions).effect).toBe("allow")
const explore = yield* agent.get(Agent.ID.make("explore"))
expect(Permission.evaluate("read", ".env", explore?.permissions ?? []).effect).toBe("ask")
expect(Permission.evaluate("read", ".env.local", explore?.permissions ?? []).effect).toBe("ask")
expect(Permission.evaluate("read", ".env.example", explore?.permissions ?? []).effect).toBe("allow")
expect(Permission.evaluate("read", "src/index.ts", explore?.permissions ?? []).effect).toBe("allow")
for (const item of agents) { for (const item of agents) {
expect(item.permissions.some((rule) => rule.action === "bash" && rule.effect !== "deny")).toBe(false) expect(item.permissions.some((rule) => rule.action === "bash" && rule.effect !== "deny")).toBe(false)
} }
@@ -194,6 +165,11 @@ describe("Agent", () => {
host({ host({
agent: agentHost(agent), agent: agentHost(agent),
}), }),
).pipe(
Effect.provideService(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
),
) )
yield* Effect.forEach(["general", "explore"], (id) => yield* Effect.forEach(["general", "explore"], (id) =>
-17
View File
@@ -98,23 +98,6 @@ it.effect("projects request settings, headers, and body overlays", () =>
}), }),
) )
it.effect("leaves max output tokens unset when the request omits them", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const resolved = yield* aisdk.model({
...model("@openrouter/ai-sdk-provider"),
limit: { context: 500_000, output: 500_000 },
})
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
expect(prepared.body.maxOutputTokens).toBeUndefined()
}),
)
it.effect("maps pro reasoning bodies to AI SDK provider options", () => it.effect("maps pro reasoning bodies to AI SDK provider options", () =>
Effect.gen(function* () { Effect.gen(function* () {
const aisdk = yield* AISDK.Service const aisdk = yield* AISDK.Service
+9 -14
View File
@@ -20,13 +20,10 @@ import { agentHost, host } from "../plugin/host"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, FSUtil.node, Global.node]))) const it = testEffect(AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, FSUtil.node, Global.node])))
const decode = Schema.decodeUnknownSync(Config.Info) const decode = Schema.decodeUnknownSync(Config.Info)
const defaultPermissions = (global: Global.Interface): Permission.Ruleset => [ const defaultPermissions = [
...Agent.Info.default(Agent.ID.make("test")).permissions, { action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: path.join(global.data, "shell", "*", "*"), effect: "allow" }, { action: "external_directory", resource: "*", effect: "ask" },
{ action: "external_directory", resource: path.join(global.data, "tool-output", "*"), effect: "allow" }, ] satisfies Permission.Ruleset
{ action: "external_directory", resource: path.join(global.tmp, "*"), effect: "allow" },
{ action: "external_directory", resource: path.join(global.config, "*"), effect: "allow" },
]
test("rejects named agent color tokens", () => { test("rejects named agent color tokens", () => {
expect(() => decode({ agents: { reviewer: { color: "warning" } } })).toThrow() expect(() => decode({ agents: { reviewer: { color: "warning" } } })).toThrow()
@@ -61,7 +58,6 @@ describe("ConfigAgentPlugin.Plugin", () => {
it.effect("applies all global permissions before agent-specific permissions", () => it.effect("applies all global permissions before agent-specific permissions", () =>
Effect.gen(function* () { Effect.gen(function* () {
const agents = yield* Agent.Service const agents = yield* Agent.Service
const global = yield* Global.Service
const build = Agent.ID.make("build") const build = Agent.ID.make("build")
yield* agents.transform((editor) => yield* agents.transform((editor) =>
editor.update(build, (agent) => { editor.update(build, (agent) => {
@@ -114,7 +110,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
const buildAgent = yield* agents.get(build) const buildAgent = yield* agents.get(build)
if (!buildAgent) throw new Error("expected configured build agent") if (!buildAgent) throw new Error("expected configured build agent")
expect(buildAgent.permissions).toEqual([ expect(buildAgent.permissions).toEqual([
...defaultPermissions(global), ...defaultPermissions,
{ action: "bash", resource: "*", effect: "allow" }, { action: "bash", resource: "*", effect: "allow" },
{ action: "bash", resource: "*", effect: "ask" }, { action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" }, { action: "read", resource: "*", effect: "allow" },
@@ -132,7 +128,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" }, model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
}) })
expect(reviewer.permissions).toEqual([ expect(reviewer.permissions).toEqual([
...defaultPermissions(global), ...defaultPermissions,
{ action: "bash", resource: "*", effect: "ask" }, { action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" }, { action: "read", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" }, { action: "edit", resource: "*", effect: "deny" },
@@ -140,7 +136,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
]) ])
expect(Permission.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny") expect(Permission.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
expect((yield* agents.get(Agent.ID.make("late")))?.permissions).toEqual([ expect((yield* agents.get(Agent.ID.make("late")))?.permissions).toEqual([
...defaultPermissions(global), ...defaultPermissions,
{ action: "bash", resource: "*", effect: "ask" }, { action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" }, { action: "read", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "allow" }, { action: "edit", resource: "*", effect: "allow" },
@@ -274,7 +270,6 @@ Use native v2 fields.`,
await fs.writeFile(path.join(tmp.path, "modes", "plan.md"), "Make a plan.") await fs.writeFile(path.join(tmp.path, "modes", "plan.md"), "Make a plan.")
}) })
const agents = yield* Agent.Service const agents = yield* Agent.Service
const global = yield* Global.Service
const entries = [ const entries = [
new Config.Document({ new Config.Document({
type: "document", type: "document",
@@ -292,13 +287,13 @@ Use native v2 fields.`,
system: "Review carefully.", system: "Review carefully.",
description: "Markdown description", description: "Markdown description",
request: { body: { temperature: 0.5 } }, request: { body: { temperature: 0.5 } },
permissions: [...defaultPermissions(global), { action: "edit", resource: "*", effect: "deny" }], permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
}) })
expect(yield* agents.get(Agent.ID.make("team/helper"))).toMatchObject({ system: "Help the team." }) expect(yield* agents.get(Agent.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
expect(yield* agents.get(Agent.ID.make("native"))).toMatchObject({ expect(yield* agents.get(Agent.ID.make("native"))).toMatchObject({
system: "Use native v2 fields.", system: "Use native v2 fields.",
request: { headers: { "x-agent": "native" }, body: { effort: "high" } }, request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
permissions: [...defaultPermissions(global), { action: "edit", resource: "*", effect: "deny" }], permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
}) })
expect(yield* agents.get(Agent.ID.make("disabled"))).toBeUndefined() expect(yield* agents.get(Agent.ID.make("disabled"))).toBeUndefined()
expect(yield* agents.get(Agent.ID.make("empty"))).toBeUndefined() expect(yield* agents.get(Agent.ID.make("empty"))).toBeUndefined()
@@ -714,7 +714,7 @@ describe("DatabaseMigration", () => {
sql`INSERT INTO event VALUES ('evt_success', 'session.tool.success.1', ${JSON.stringify({ sql`INSERT INTO event VALUES ('evt_success', 'session.tool.success.1', ${JSON.stringify({
sessionID: "ses_test", sessionID: "ses_test",
assistantMessageID: "msg_tools", assistantMessageID: "msg_tools",
id: "call_hosted", callID: "call_hosted",
structured: {}, structured: {},
content: [], content: [],
result: { type: "json", value: [{ url: "https://example.com" }] }, 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({ sql`INSERT INTO event VALUES ('evt_failed', 'session.tool.failed.1', ${JSON.stringify({
sessionID: "ses_test", sessionID: "ses_test",
assistantMessageID: "msg_tools", assistantMessageID: "msg_tools",
id: "call_failed", callID: "call_failed",
error: { type: "tool.execution", message: "timed out" }, error: { type: "tool.execution", message: "timed out" },
metadata: { truncated: false }, metadata: { truncated: false },
executed: false, executed: false,
@@ -795,7 +795,7 @@ describe("DatabaseMigration", () => {
expect(JSON.parse(event!.data)).toEqual({ expect(JSON.parse(event!.data)).toEqual({
sessionID: "ses_test", sessionID: "ses_test",
assistantMessageID: "msg_tools", assistantMessageID: "msg_tools",
id: "call_hosted", callID: "call_hosted",
structured: {}, structured: {},
content: [], content: [],
result: { type: "json", value: [{ url: "https://example.com" }] }, result: { type: "json", value: [{ url: "https://example.com" }] },
@@ -806,7 +806,7 @@ describe("DatabaseMigration", () => {
expect(JSON.parse(failedEvent!.data)).toEqual({ expect(JSON.parse(failedEvent!.data)).toEqual({
sessionID: "ses_test", sessionID: "ses_test",
assistantMessageID: "msg_tools", assistantMessageID: "msg_tools",
id: "call_failed", callID: "call_failed",
error: { type: "tool.execution", message: "timed out" }, error: { type: "tool.execution", message: "timed out" },
metadata: { truncated: false }, metadata: { truncated: false },
executed: false, executed: false,
+7 -26
View File
@@ -153,16 +153,12 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) {
function withTmp<A, E, R>( function withTmp<A, E, R>(
f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>, f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
options?: { vcs?: "git" | "hg"; init?: (directory: string) => Promise<void> }, options?: { git?: boolean; init?: (directory: string) => Promise<void> },
) { ) {
return Effect.acquireRelease( return Effect.acquireRelease(
Effect.promise(async () => { Effect.promise(async () => {
const tmp = await tmpdir() const tmp = await tmpdir()
if (options?.vcs === "hg") { if (!options?.git) return { tmp, vcs: undefined }
await fs.mkdir(path.join(tmp.path, ".hg"))
return { tmp, vcs: { type: "hg" as const, store: AbsolutePath.make(path.join(tmp.path, ".hg")) } }
}
if (options?.vcs !== "git") return { tmp, vcs: undefined }
await $`git init`.cwd(tmp.path).quiet() await $`git init`.cwd(tmp.path).quiet()
await $`git config core.fsmonitor false`.cwd(tmp.path).quiet() await $`git config core.fsmonitor false`.cwd(tmp.path).quiet()
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet() await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
@@ -296,7 +292,7 @@ describeWatcher("LocationWatcher", () => {
}) })
} }
}), }),
{ vcs: "git" }, { git: true },
), ),
) )
@@ -326,7 +322,7 @@ describeWatcher("LocationWatcher", () => {
}), }),
) )
}), }),
{ vcs: "git" }, { git: true },
), ),
) )
@@ -363,7 +359,7 @@ describeWatcher("LocationWatcher", () => {
.pipe(Effect.andThen(Effect.promise(() => $`git add .`.cwd(directory).quiet())), Effect.asVoid), .pipe(Effect.andThen(Effect.promise(() => $`git add .`.cwd(directory).quiet())), Effect.asVoid),
) )
}), }),
{ vcs: "git" }, { git: true },
), ),
) )
@@ -380,7 +376,7 @@ describeWatcher("LocationWatcher", () => {
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)), yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
).toMatchObject({ file: head }) ).toMatchObject({ file: head })
}), }),
{ vcs: "git" }, { git: true },
), ),
) )
@@ -405,7 +401,7 @@ describeWatcher("LocationWatcher", () => {
).toEqual({ file: path.join(actual, "HEAD"), event: "change" }) ).toEqual({ file: path.join(actual, "HEAD"), event: "change" })
}), }),
{ {
vcs: "git", git: true,
init: async (directory) => { init: async (directory) => {
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`) const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
await fs.rename(path.join(directory, ".git"), actual) await fs.rename(path.join(directory, ".git"), actual)
@@ -415,19 +411,4 @@ describeWatcher("LocationWatcher", () => {
), ),
) )
}) })
it.live("publishes .hg/branch events", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const branch = path.join(directory, ".hg", "branch")
yield* ready(directory)
expect(
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
).toMatchObject({ file: branch })
}),
{ vcs: "hg" },
),
)
}) })
+2 -2
View File
@@ -5,8 +5,8 @@ import path from "path"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
describe("global paths", () => { describe("global paths", () => {
test("tmp path is the canonical system temp directory", async () => { test("tmp path is under the system temp directory", () => {
expect(Global.Path.tmp).toBe(await fs.realpath(path.join(os.tmpdir(), "opencode"))) expect(Global.Path.tmp).toBe(path.join(os.tmpdir(), "opencode"))
expect(Global.make().tmp).toBe(Global.Path.tmp) expect(Global.make().tmp).toBe(Global.Path.tmp)
}) })
@@ -29,7 +29,7 @@ const locationLayer = Layer.succeed(
const it = testEffect( const it = testEffect(
AppNodeBuilder.build(InstructionBuiltIns.node, [ AppNodeBuilder.build(InstructionBuiltIns.node, [
[Location.node, locationLayer], [Location.node, locationLayer],
[Global.node, Global.layerWith({ config: "/global", tmp: "/temporary" })], [Global.node, Global.layerWith({ config: "/global" })],
]), ]),
) )
@@ -49,7 +49,6 @@ describe("InstructionBuiltIns", () => {
` Workspace root folder: ${projectDirectory}`, ` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes", " Is directory a git repo: yes",
` Platform: ${process.platform}`, ` Platform: ${process.platform}`,
" Use /temporary for temporary work outside the workspace; it already exists and is pre-approved for external directory access.",
"</env>", "</env>",
"", "",
`Today's date: ${localDate(timestamp)}`, `Today's date: ${localDate(timestamp)}`,
+1 -1
View File
@@ -928,7 +928,7 @@ it.effect("waits for permission before calling an MCP tool", () =>
source: { source: {
type: "tool", type: "tool",
messageID: toolIdentity.messageID, messageID: toolIdentity.messageID,
id: "call_mcp_permission", callID: "call_mcp_permission",
}, },
}) })
expect(calls).toBe(0) expect(calls).toBe(0)
+15 -114
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/ai" import { Message, SystemPart } from "@opencode-ai/ai"
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect" import { DateTime, Effect, Schema } from "effect"
import { Agent } from "@opencode-ai/core/agent" import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Model } from "@opencode-ai/core/model" import { Model } from "@opencode-ai/core/model"
@@ -15,7 +15,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
import { Tool } from "@opencode-ai/core/tool" import { Tool } from "@opencode-ai/core/tool"
import { Provider } from "@opencode-ai/core/provider" import { Provider } from "@opencode-ai/core/provider"
import { define } from "@opencode-ai/plugin/promise/plugin" import { define } from "@opencode-ai/plugin/promise/plugin"
import type { SessionHooks, SessionHttpHandler } from "@opencode-ai/plugin/effect/session" import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture" import { PluginTestLayer } from "./fixture"
import { host as testHost } from "./host" import { host as testHost } from "./host"
@@ -148,9 +148,7 @@ describe("fromPromise", () => {
expect((await ctx.agent.get({ agentID: Agent.ID.make("reviewer") })).data).toMatchObject({ expect((await ctx.agent.get({ agentID: Agent.ID.make("reviewer") })).data).toMatchObject({
description: "Reviews code", description: "Reviews code",
}) })
await expect(ctx.agent.get({ agentID: Agent.ID.make("missing") })).rejects.toThrow( await expect(ctx.agent.get({ agentID: Agent.ID.make("missing") })).rejects.toThrow("Agent not found: missing")
"Agent not found: missing",
)
const models = (await ctx.catalog.model.list()).data const models = (await ctx.catalog.model.list()).data
expect(models.find((model) => model.providerID === "test" && model.id === "alias")).toMatchObject({ expect(models.find((model) => model.providerID === "test" && model.id === "alias")).toMatchObject({
modelID: "gpt-5", modelID: "gpt-5",
@@ -223,105 +221,6 @@ describe("fromPromise", () => {
}), }),
) )
it.effect("adapts promise session HTTP hooks", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin)
const bodies: string[] = []
yield* PluginPromise.fromPromise(
define({
id: "promise-session-http",
setup: async (ctx) => {
await ctx.session.hook("http", (event) => {
event.use(async (request, next) => {
request.headers.set("x-hook", "promise")
await next(request)
const response = await next(request)
return new Response(`${await response.text()}-response`)
})
})
await ctx.session.hook("http", (event) => {
event.use(async (request, next) => {
const response = await next(request)
return new Response(`${await response.text()}-outer`)
})
})
},
}),
).effect(host)
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
const event: PluginHooks.Domains["session"]["http"] = {
sessionID: Session.ID.make("ses_promise_session_http"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
}
yield* hooks.trigger("session", "http", event)
const request = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
(input: Request) =>
Effect.promise(() => input.text()).pipe(
Effect.tap((body) => Effect.sync(() => bodies.push(body))),
Effect.as(new Response(input.headers.get("x-hook") ?? "missing")),
),
)
const response = yield* request(new Request("https://provider.test", { method: "POST", body: "payload" }))
expect(bodies).toEqual(["payload", "payload"])
expect(yield* Effect.promise(() => response.text())).toBe("promise-response-outer")
}),
)
it.effect("interrupts the Effect request through a promise session HTTP hook", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin)
yield* PluginPromise.fromPromise(
define({
id: "promise-session-http-interrupt",
setup: async (ctx) => {
await ctx.session.hook("http", (event) => {
event.use((request, next) => next(request))
})
},
}),
).effect(host)
const started = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
const event: PluginHooks.Domains["session"]["http"] = {
sessionID: Session.ID.make("ses_promise_session_http_interrupt"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
}
yield* hooks.trigger("session", "http", event)
const request = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
() =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
),
)
const fiber = yield* request(new Request("https://provider.test")).pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* Fiber.interrupt(fiber)
expect(yield* Deferred.isDone(interrupted)).toBeTrue()
}),
)
it.effect("disposes a hook registration on request", () => it.effect("disposes a hook registration on request", () =>
Effect.gen(function* () { Effect.gen(function* () {
const agents = yield* Agent.Service const agents = yield* Agent.Service
@@ -416,17 +315,19 @@ describe("fromPromise", () => {
id: "promise-tool", id: "promise-tool",
setup: async (ctx) => { setup: async (ctx) => {
await ctx.tool.transform((tools) => { await ctx.tool.transform((tools) => {
tools.add({ tools.add(
name: "hello", {
options: { codemode: false }, name: "hello",
description: "Hello", options: { codemode: false },
input: Schema.Struct({ name: Schema.String }), description: "Hello",
output: Schema.String, input: Schema.Struct({ name: Schema.String }),
execute: async ({ name }, context) => { output: Schema.String,
await context.progress({ phase: "greeting" }) execute: async ({ name }, context) => {
return { output: `Hello, ${name}!` } await context.progress({ phase: "greeting" })
return { output: `Hello, ${name}!` }
},
}, },
}) )
}) })
}, },
}) })
@@ -0,0 +1,103 @@
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")
}),
)
})
@@ -12,7 +12,6 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai" import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
import { Provider } from "@opencode-ai/core/provider" import { Provider } from "@opencode-ai/core/provider"
import type { SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture" import { PluginTestLayer } from "./fixture"
@@ -30,29 +29,6 @@ function required<T>(value: T | undefined): T {
return value return value
} }
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
yield* (yield* PluginHooks.Service).trigger("session", "http", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
})
const request = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
(input: Request) => {
const headers = new Headers(input.headers)
headers.set("x-seen-url", input.url)
return Effect.succeed(new Response(null, { headers }))
},
)
const response = yield* request(new Request(url, { method: "POST", body: "{}" }))
return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) }
})
describe("OpenAIPlugin", () => { describe("OpenAIPlugin", () => {
it.effect("registers browser and headless ChatGPT OAuth methods", () => it.effect("registers browser and headless ChatGPT OAuth methods", () =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -124,9 +100,33 @@ describe("OpenAIPlugin", () => {
}) })
yield* addPlugin() yield* addPlugin()
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses") const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
const custom = yield* http(Provider.ID.make("custom-openai"), "https://custom.example/v1/responses") sessionID: Session.ID.make("ses_test"),
const proxy = yield* http(Provider.ID.openai, "https://proxy.example/v1/responses?region=us") agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://api.openai.com/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const custom = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("custom-openai"), id: Model.ID.make("gpt-5.5") }),
url: "https://custom.example/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const proxy = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://proxy.example/v1/responses?region=us",
method: "POST",
headers: {},
body: "{}",
})
const provider = required(yield* catalog.provider.get(Provider.ID.openai)) const provider = required(yield* catalog.provider.get(Provider.ID.openai))
expect(provider.package).toBe("@opencode-ai/ai/providers/openai") expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
@@ -134,7 +134,7 @@ describe("OpenAIPlugin", () => {
expect(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" }) expect(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses") expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" }) expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
expect(custom.headers).not.toHaveProperty("originator") expect(custom.headers).toEqual({})
expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us") expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us")
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" }) expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))) const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
@@ -184,13 +184,21 @@ describe("OpenAIPlugin", () => {
}) })
yield* addPlugin() yield* addPlugin()
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses") const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://api.openai.com/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))) const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(model.package).toBe("@opencode-ai/ai/providers/openai") expect(model.package).toBe("@opencode-ai/ai/providers/openai")
expect(model.enabled).toBe(true) expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 }) expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(request.headers).not.toHaveProperty("originator") expect(request.headers).toEqual({})
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true) expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
}), }),
) )
@@ -1,3 +1,4 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
@@ -14,6 +15,7 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () { const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin) const host = yield* PluginHost.make(plugin)
yield* OpenRouterPlugin.effect(host) yield* OpenRouterPlugin.effect(host)
}) })
@@ -44,6 +46,36 @@ 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", () => it.effect("filters OpenRouter's gpt-5 chat alias", () =>
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
@@ -1,9 +1,13 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Integration } from "@opencode-ai/core/integration" import { Integration } from "@opencode-ai/core/integration"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin" import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginHost } from "@opencode-ai/core/plugin/host"
import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai" import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture" import { PluginTestLayer } from "./fixture"
@@ -15,6 +19,19 @@ const addPlugin = Effect.fn(function* () {
yield* XAIPlugin.effect(host) 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", () => { describe("XAIPlugin", () => {
it.effect("registers browser OAuth, device OAuth, and API key methods", () => it.effect("registers browser OAuth, device OAuth, and API key methods", () =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -37,4 +54,95 @@ 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()
}),
)
}) })
@@ -0,0 +1,32 @@
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")
})
+5 -71
View File
@@ -22,7 +22,6 @@ import { App } from "@opencode-ai/core/app"
import { Agent } from "@opencode-ai/core/agent" import { Agent } from "@opencode-ai/core/agent"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Base64, FileAttachment } from "@opencode-ai/schema/prompt"
import { Money } from "@opencode-ai/schema/money" import { Money } from "@opencode-ai/schema/money"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect" import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { asc, eq } from "drizzle-orm" import { asc, eq } from "drizzle-orm"
@@ -114,24 +113,6 @@ test("compaction describes tool media without embedding base64", () => {
expect(serialized).not.toContain(base64) expect(serialized).not.toContain(base64)
}) })
test("compaction estimates media context without counting base64", () => {
const image = FileAttachment.make({
data: Base64.make("a".repeat(10_000)),
mime: "image/png",
source: { type: "inline" },
name: "image.png",
})
const message = SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text: "Compare these images.",
files: [image, image, FileAttachment.make({ ...image, mime: "application/pdf" })],
time: { created: DateTime.makeUnsafe(0) },
})
expect(SessionCompaction.estimateMediaTokens(message)).toBe(4_500)
})
test("compaction prompt requires the checkpoint headings in order", () => { test("compaction prompt requires the checkpoint headings in order", () => {
const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] }) const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] })
expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([ expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([
@@ -197,7 +178,7 @@ it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
}), }),
) )
it.effect("manual compaction preserves ordered media in the retained tail", () => it.effect("manual compaction summarizes short context instead of no-op", () =>
Effect.gen(function* () { Effect.gen(function* () {
requests = [] requests = []
const db = (yield* Database.Service).db const db = (yield* Database.Service).db
@@ -209,35 +190,9 @@ it.effect("manual compaction preserves ordered media in the retained tail", () =
const userMessage = { const userMessage = {
id: SessionMessage.ID.create(), id: SessionMessage.ID.create(),
type: "user" as const, type: "user" as const,
text: `Manual compaction should include this older conversation. ${"older context ".repeat(4_500)}`, text: "Manual compaction should include this short conversation.",
time: { created: DateTime.makeUnsafe(0) }, time: { created: DateTime.makeUnsafe(0) },
} }
const recentMessage = SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text: "Compare the retained media.",
files: [
FileAttachment.make({
data: Base64.make("aW1hZ2U="),
mime: "application/pdf",
source: { type: "inline" },
name: "prompt.pdf",
}),
FileAttachment.make({
data: Base64.make("aW1hZ2U="),
mime: "image/png",
source: { type: "inline" },
name: "prompt.png",
}),
],
time: { created: DateTime.makeUnsafe(1) },
})
const latestMessage = SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text: "Newest text after the retained media.",
time: { created: DateTime.makeUnsafe(2) },
})
yield* db yield* db
.insert(ProjectTable) .insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
@@ -273,7 +228,7 @@ it.effect("manual compaction preserves ordered media in the retained tail", () =
expect( expect(
yield* compaction.compactManual({ yield* compaction.compactManual({
session, session,
messages: [userMessage, recentMessage, latestMessage], messages: [userMessage],
inputID: SessionMessage.ID.make("msg_manual_compaction"), inputID: SessionMessage.ID.make("msg_manual_compaction"),
}), }),
).toEqual({ status: "completed" }) ).toEqual({ status: "completed" })
@@ -290,30 +245,9 @@ it.effect("manual compaction preserves ordered media in the retained tail", () =
"x-opencode-client": "opencode", "x-opencode-client": "opencode",
}) })
expect(requests[0]?.generation).toBeUndefined() expect(requests[0]?.generation).toBeUndefined()
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this older conversation.") expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
expect(yield* store.context(sessionID)).toMatchObject([ expect(yield* store.context(sessionID)).toMatchObject([
{ { type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
type: "compaction",
reason: "manual",
summary: "manual summary",
recent: expect.stringMatching(
/\[User\]: Compare the retained media\.\n\[Attached application\/pdf: prompt\.pdf\]\n\[Attached image\/png: prompt\.png\]\n\n\[User\]: Newest text after the retained media\./,
),
media: [
{
type: "file",
uri: "data:application/pdf;base64,aW1hZ2U=",
mime: "application/pdf",
name: "prompt.pdf",
},
{
type: "file",
uri: "data:image/png;base64,aW1hZ2U=",
mime: "image/png",
name: "prompt.png",
},
],
},
]) ])
expect(yield* store.get(sessionID)).toMatchObject({ expect(yield* store.get(sessionID)).toMatchObject({
cost: 0.0000233, cost: 0.0000233,
+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, { yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID, sessionID,
assistantMessageID: activeAssistant, assistantMessageID: activeAssistant,
id: "active-call", callID: "active-call",
name: "echo", name: "echo",
}) })
yield* bus.publish(SessionEvent.Tool.Input.Ended, { yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID, sessionID,
assistantMessageID: activeAssistant, assistantMessageID: activeAssistant,
id: "active-call", callID: "active-call",
text: "{}", text: "{}",
}) })
yield* bus.publish(SessionEvent.Tool.Called, { yield* bus.publish(SessionEvent.Tool.Called, {
sessionID, sessionID,
assistantMessageID: activeAssistant, assistantMessageID: activeAssistant,
id: "active-call", callID: "active-call",
input: {}, input: {},
executed: false, executed: false,
}) })
@@ -102,15 +102,7 @@ describe("toLLMMessages", () => {
status: "completed", status: "completed",
reason: "auto", reason: "auto",
summary: "Earlier work", summary: "Earlier work",
recent: "Recent work\n[Attached image/png: retained.png]", recent: "Recent work",
media: [
{
type: "file",
uri: "data:image/png;base64,aGVsbG8=",
mime: "image/png",
name: "retained.png",
},
],
time: { created }, time: { created },
}), }),
], ],
@@ -150,16 +142,9 @@ Earlier work
<recent-context> <recent-context>
Recent work Recent work
[Attached image/png: retained.png]
</recent-context> </recent-context>
</conversation-checkpoint>`, </conversation-checkpoint>`,
}, },
{
type: "media",
mediaType: "image/png",
data: "data:image/png;base64,aGVsbG8=",
filename: "retained.png",
},
], ],
]) ])
}) })
@@ -41,7 +41,6 @@ import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { Effect, Layer, Stream } from "effect" import { Effect, Layer, Stream } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import path from "node:path" import path from "node:path"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { agentHost, catalogHost, host } from "./plugin/host" import { agentHost, catalogHost, host } from "./plugin/host"
@@ -105,39 +104,37 @@ const promptCatalog = Layer.mock(Catalog.Service, {
small: () => Effect.succeed(undefined), small: () => Effect.succeed(undefined),
}, },
}) })
const runnerLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) => const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
AppNodeBuilder.build(SessionRunnerLLM.node, [ [Snapshot.node, Snapshot.noopLayer],
[Snapshot.node, Snapshot.noopLayer], [LayerNodePlatform.llmClient, client],
[LayerNodePlatform.llmClient, llmClient], [SessionRunnerModel.node, models],
[SessionRunnerModel.node, models], [InstructionBuiltIns.node, systemContext],
[InstructionBuiltIns.node, systemContext], [InstructionDiscovery.node, instructionContext],
[InstructionDiscovery.node, instructionContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillInstructions.node, skillInstructions],
[SkillInstructions.node, skillInstructions], [ReferenceInstructions.node, referenceInstructions],
[ReferenceInstructions.node, referenceInstructions], [McpInstructions.node, mcpInstructions],
[McpInstructions.node, mcpInstructions], [Config.node, config],
[Config.node, config], [Permission.node, permission],
[Permission.node, permission], [PluginSupervisor.node, pluginSupervisor],
[PluginSupervisor.node, pluginSupervisor], ])
]) const execution = Layer.effect(
const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) => SessionExecution.Service,
Layer.effect( Effect.gen(function* () {
SessionExecution.Service, const sessionRunner = yield* SessionRunner.Service
Effect.gen(function* () { const coordinator = yield* SessionRunCoordinator.make<Session.ID, SessionRunner.RunError>({
const sessionRunner = yield* SessionRunner.Service drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }),
const coordinator = yield* SessionRunCoordinator.make<Session.ID, SessionRunner.RunError>({ })
drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }), return SessionExecution.Service.of({
}) active: coordinator.active,
return SessionExecution.Service.of({ resume: coordinator.run,
active: coordinator.active, wake: coordinator.wake,
resume: coordinator.run, interrupt: coordinator.interrupt,
wake: coordinator.wake, awaitIdle: coordinator.awaitIdle,
interrupt: coordinator.interrupt, })
awaitIdle: coordinator.awaitIdle, }),
}) ).pipe(Layer.provide(runnerLayer))
}), const it = testEffect(
).pipe(Layer.provide(runnerLayer(llmClient)))
const testLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
AppNodeBuilder.build( AppNodeBuilder.build(
LayerNode.group([ LayerNode.group([
Database.node, Database.node,
@@ -159,7 +156,7 @@ const testLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
Session.node, Session.node,
]), ]),
[ [
[LayerNodePlatform.llmClient, llmClient], [LayerNodePlatform.llmClient, client],
[Permission.node, permission], [Permission.node, permission],
[Catalog.node, promptCatalog], [Catalog.node, promptCatalog],
[SessionRunnerModel.node, models], [SessionRunnerModel.node, models],
@@ -171,10 +168,10 @@ const testLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
[Config.node, config], [Config.node, config],
[Snapshot.node, Snapshot.noopLayer], [Snapshot.node, Snapshot.noopLayer],
[PluginSupervisor.node, pluginSupervisor], [PluginSupervisor.node, pluginSupervisor],
[SessionExecution.node, execution(llmClient)], [SessionExecution.node, execution],
], ],
) ),
const it = testEffect(testLayer(client)) )
const sessionID = Session.ID.make("ses_runner_recorded") const sessionID = Session.ID.make("ses_runner_recorded")
describe("SessionRunnerLLM recorded", () => { describe("SessionRunnerLLM recorded", () => {
@@ -250,90 +247,3 @@ describe("SessionRunnerLLM recorded", () => {
}), }),
) )
}) })
describe("SessionModelRequest HTTP bridge", () => {
const bodies: Uint8Array[] = []
const methods: string[] = []
const response = [
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}]}',
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
"data: [DONE]",
"",
].join("\n\n")
const transport = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
methods.push(request.method)
bodies.push(request.body.body.slice())
return HttpClientResponse.fromWeb(
request,
new Response(response, { headers: { "content-type": "text/event-stream" } }),
)
}),
),
)
const retryIt = testEffect(
testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))),
)
retryIt.effect("lets an Effect plugin send the same POST Request twice", () =>
Effect.gen(function* () {
bodies.length = 0
methods.length = 0
const agents = yield* Agent.Service
const catalog = yield* Catalog.Service
const hooks = yield* PluginHooks.Service
yield* agents.transform((draft) =>
draft.update(Agent.ID.make("build"), (agent) => {
agent.mode = "primary"
agent.permissions.push({ action: "execute", resource: "*", effect: "deny" })
}),
)
const pluginHost = host({
agent: agentHost(agents),
catalog: catalogHost(catalog),
session: { hook: (name, callback) => hooks.register("session", name, callback) },
})
yield* pluginHost.session.hook("http", (event) =>
event.use((request, next) =>
Effect.gen(function* () {
yield* next(request).pipe(Effect.flatMap((response) => Effect.promise(() => response.text())))
return yield* next(request)
}),
),
)
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
const retrySessionID = Session.ID.make("ses_model_request_http_retry")
yield* db
.insert(SessionTable)
.values({
id: retrySessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const session = yield* Session.Service
yield* session.prompt({ sessionID: retrySessionID, text: "Say hello.", resume: false })
yield* session.resume(retrySessionID)
expect(methods).toEqual(["POST", "POST"])
expect(bodies).toHaveLength(2)
expect(bodies[0]?.byteLength).toBeGreaterThan(0)
expect(bodies[1]).toEqual(bodies[0])
}),
)
})
@@ -237,7 +237,7 @@ test("success event data can carry provider-executed result state", () => {
const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({ const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
sessionID, sessionID,
assistantMessageID: SessionMessage.ID.create(), assistantMessageID: SessionMessage.ID.create(),
id: "call-old", callID: "call-old",
content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }], content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }],
executed: true, executed: true,
resultState: { resultState: {
@@ -339,7 +339,7 @@ describe("Tool", () => {
call: { type: "tool-call", id: "call-context", name: "context", input: {} }, call: { type: "tool-call", id: "call-context", name: "context", input: {} },
}) })
expect(contexts).toEqual([ expect(contexts).toEqual([
{ sessionID, ...identity, id: Tool.CallID.make("call-context"), progress: expect.any(Function) }, { sessionID, ...identity, callID: Tool.CallID.make("call-context"), progress: expect.any(Function) },
]) ])
}), }),
) )
+19 -40
View File
@@ -887,27 +887,6 @@ describe("SessionRunnerLLM", () => {
}), }),
) )
it.effect("executes a tool renamed by a session context hook", () =>
Effect.gen(function* () {
const session = yield* setup
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
event.tools.renamed_echo = event.tools.echo!
delete event.tools.echo
}),
)
yield* admit(session, "Use the renamed tool")
yield* TestLLM.push(TestLLM.tool("call-renamed", "renamed_echo", { text: "renamed" }), [])
yield* session.resume(sessionID)
expect(requests[0]?.tools.map((tool) => tool.name)).toContain("renamed_echo")
expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("echo")
expect(executions).toEqual(["renamed"])
}),
)
it.effect("advertises and executes a location registered tool", () => it.effect("advertises and executes a location registered tool", () =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* setup const session = yield* setup
@@ -935,7 +914,7 @@ describe("SessionRunnerLLM", () => {
yield* TestLLM.push(TestLLM.tool("call-location", "location_context", { query: "hello" }), []) yield* TestLLM.push(TestLLM.tool("call-location", "location_context", { query: "hello" }), [])
const bus = yield* Bus.Service const bus = yield* Bus.Service
const progressFiber = yield* bus.subscribe(SessionEvent.Tool.Progress).pipe( const progressFiber = yield* bus.subscribe(SessionEvent.Tool.Progress).pipe(
Stream.filter((event) => event.data.sessionID === sessionID && event.data.id === "call-location"), Stream.filter((event) => event.data.sessionID === sessionID && event.data.callID === "call-location"),
Stream.take(1), Stream.take(1),
Stream.runCollect, Stream.runCollect,
Effect.forkScoped({ startImmediately: true }), Effect.forkScoped({ startImmediately: true }),
@@ -949,7 +928,7 @@ describe("SessionRunnerLLM", () => {
sessionID, sessionID,
agent: Agent.ID.make("build"), agent: Agent.ID.make("build"),
messageID: expect.stringMatching(/^msg_/), messageID: expect.stringMatching(/^msg_/),
id: Tool.CallID.make("call-location"), callID: Tool.CallID.make("call-location"),
progress: expect.any(Function), progress: expect.any(Function),
}, },
]) ])
@@ -2382,7 +2361,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2) expect(requests).toHaveLength(2)
expect(messageRoles(requests[1])).toEqual(["user", "assistant", "tool"]) expect(messageRoles(requests[1])).toEqual(["user", "assistant", "tool"])
expect(authorizations).toMatchObject([{ sessionID, id: "call-echo" }]) expect(authorizations).toMatchObject([{ sessionID, callID: "call-echo" }])
expect(executions).toEqual(["hello"]) expect(executions).toEqual(["hello"])
const context = yield* session.context(sessionID) const context = yield* session.context(sessionID)
expect(context).toMatchObject([ expect(context).toMatchObject([
@@ -2994,19 +2973,19 @@ describe("SessionRunnerLLM", () => {
yield* bus.publish(SessionEvent.Tool.Input.Started, { yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
id: "call-interrupted", callID: "call-interrupted",
name: "echo", name: "echo",
}) })
yield* bus.publish(SessionEvent.Tool.Input.Ended, { yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
id: "call-interrupted", callID: "call-interrupted",
text: '{"text":"stale"}', text: '{"text":"stale"}',
}) })
yield* bus.publish(SessionEvent.Tool.Called, { yield* bus.publish(SessionEvent.Tool.Called, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
id: "call-interrupted", callID: "call-interrupted",
input: { text: "stale" }, input: { text: "stale" },
executed: false, executed: false,
}) })
@@ -3051,19 +3030,19 @@ describe("SessionRunnerLLM", () => {
yield* bus.publish(SessionEvent.Tool.Input.Started, { yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
id: "call-hosted-interrupted", callID: "call-hosted-interrupted",
name: "web_search", name: "web_search",
}) })
yield* bus.publish(SessionEvent.Tool.Input.Ended, { yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
id: "call-hosted-interrupted", callID: "call-hosted-interrupted",
text: '{"query":"stale"}', text: '{"query":"stale"}',
}) })
yield* bus.publish(SessionEvent.Tool.Called, { yield* bus.publish(SessionEvent.Tool.Called, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
id: "call-hosted-interrupted", callID: "call-hosted-interrupted",
input: { query: "stale" }, input: { query: "stale" },
executed: true, executed: true,
state: { itemId: "call-hosted-interrupted" }, state: { itemId: "call-hosted-interrupted" },
@@ -3102,7 +3081,7 @@ describe("SessionRunnerLLM", () => {
yield* bus.publish(SessionEvent.Tool.Input.Started, { yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
id: "call-pending-interrupted", callID: "call-pending-interrupted",
name: "echo", name: "echo",
}) })
requests.length = 0 requests.length = 0
@@ -4120,7 +4099,7 @@ describe("SessionRunnerLLM", () => {
{ {
type: "session.tool.failed.2", type: "session.tool.failed.2",
data: { data: {
id: "call-malformed", callID: "call-malformed",
error: { type: "provider.invalid-output", message: "Invalid JSON input for tool call echo" }, error: { type: "provider.invalid-output", message: "Invalid JSON input for tool call echo" },
}, },
}, },
@@ -4220,7 +4199,7 @@ describe("SessionRunnerLLM", () => {
.all() .all()
.pipe(Effect.orDie) .pipe(Effect.orDie)
expect(durable.find((event) => event.type === "session.tool.input.ended.1")?.data).toMatchObject({ expect(durable.find((event) => event.type === "session.tool.input.ended.1")?.data).toMatchObject({
id: "call-malformed", callID: "call-malformed",
text: raw, text: raw,
}) })
}), }),
@@ -4616,13 +4595,13 @@ describe("SessionRunnerLLM", () => {
const assistant = requireAssistant(yield* session.context(sessionID)) const assistant = requireAssistant(yield* session.context(sessionID))
const bus = yield* recordedStepSettlementEvents(sessionID, assistant.id) const bus = yield* recordedStepSettlementEvents(sessionID, assistant.id)
expect(bus.map((event) => ({ type: event.type, id: event.data.id }))).toEqual([ expect(bus.map((event) => ({ type: event.type, callID: event.data.callID }))).toEqual([
{ type: "session.step.started.1", id: undefined }, { type: "session.step.started.1", callID: undefined },
{ type: "session.tool.called.1", id: "call-local-raw-failure" }, { type: "session.tool.called.1", callID: "call-local-raw-failure" },
{ type: "session.tool.called.1", id: "call-hosted-raw-failure-pair" }, { type: "session.tool.called.1", callID: "call-hosted-raw-failure-pair" },
{ type: "session.tool.failed.2", id: "call-local-raw-failure" }, { type: "session.tool.failed.2", callID: "call-local-raw-failure" },
{ type: "session.tool.failed.2", id: "call-hosted-raw-failure-pair" }, { type: "session.tool.failed.2", callID: "call-hosted-raw-failure-pair" },
{ type: "session.step.failed.1", id: undefined }, { type: "session.step.failed.1", callID: undefined },
]) ])
expect( expect(
bus.filter((event) => event.type.startsWith("session.step.") && event.type !== "session.step.started.1"), 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") if (!row) return yield* Effect.die("Missing projected assistant")
return Schema.decodeUnknownSync(SessionMessage.Assistant)({ ...row.data, id: row.id, type: row.type }) return Schema.decodeUnknownSync(SessionMessage.Assistant)({ ...row.data, id: row.id, type: row.type })
}) })
const start = (id: string) => const start = (callID: string) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* service.publish(SessionEvent.Tool.Input.Started, { yield* service.publish(SessionEvent.Tool.Input.Started, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
id, callID,
name: "bash", name: "bash",
}) })
yield* service.publish(SessionEvent.Tool.Called, { yield* service.publish(SessionEvent.Tool.Called, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
id, callID,
input: { command: "pwd" }, input: { command: "pwd" },
executed: false, executed: false,
}) })
@@ -90,7 +90,7 @@ describe("Tool.Metadata", () => {
const progress = yield* service.publish(SessionEvent.Tool.Progress, { const progress = yield* service.publish(SessionEvent.Tool.Progress, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
id: "call-success", callID: "call-success",
metadata: { phase: "checkpoint" }, metadata: { phase: "checkpoint" },
}) })
expect((yield* readAssistant).content[0]).toMatchObject({ expect((yield* readAssistant).content[0]).toMatchObject({
@@ -100,7 +100,7 @@ describe("Tool.Metadata", () => {
const success = yield* service.publish(SessionEvent.Tool.Success, { const success = yield* service.publish(SessionEvent.Tool.Success, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
id: "call-success", callID: "call-success",
metadata: { phase: "done" }, metadata: { phase: "done" },
content: content("complete"), content: content("complete"),
executed: false, executed: false,
@@ -113,13 +113,13 @@ describe("Tool.Metadata", () => {
yield* service.publish(SessionEvent.Tool.Progress, { yield* service.publish(SessionEvent.Tool.Progress, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
id: "call-failed", callID: "call-failed",
metadata: { phase: "checkpoint" }, metadata: { phase: "checkpoint" },
}) })
const failed = yield* service.publish(SessionEvent.Tool.Failed, { const failed = yield* service.publish(SessionEvent.Tool.Failed, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
id: "call-failed", callID: "call-failed",
error: { type: "unknown", message: "boom" }, error: { type: "unknown", message: "boom" },
metadata: { phase: "checkpoint" }, metadata: { phase: "checkpoint" },
content: content("before failure"), content: content("before failure"),
+1 -1
View File
@@ -12,7 +12,7 @@ const context = {
sessionID: Session.ID.make("ses_execute"), sessionID: Session.ID.make("ses_execute"),
agent: Agent.ID.make("build"), agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_execute"), messageID: SessionMessage.ID.make("msg_execute"),
id: Tool.CallID.make("call_execute"), callID: Tool.CallID.make("call_execute"),
progress: () => Effect.void, progress: () => Effect.void,
} }
+2 -2
View File
@@ -166,7 +166,7 @@ describe("QuestionTool", () => {
expect(capturedInput()).toEqual({ expect(capturedInput()).toEqual({
sessionID, sessionID,
title: "Questions", title: "Questions",
metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, id: "call-question" } }, metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, callID: "call-question" } },
fields: [ fields: [
{ {
key: "q0", key: "q0",
@@ -212,7 +212,7 @@ describe("QuestionTool", () => {
expect(capturedInput()).toEqual({ expect(capturedInput()).toEqual({
sessionID, sessionID,
title: "Questions", title: "Questions",
metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, id: "call-question" } }, metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, callID: "call-question" } },
fields: [ fields: [
{ {
key: "q0", key: "q0",
+17 -52
View File
@@ -2,14 +2,11 @@ import { $ } from "bun"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { Effect, Fiber, Layer, Stream } from "effect" import { Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Vcs } from "@opencode-ai/core/vcs" import { Vcs } from "@opencode-ai/core/vcs"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect" import { it } from "./lib/effect"
@@ -18,7 +15,7 @@ const describeHg = Bun.which("hg") ? describe : describe.skip
const provide = (directory: string) => const provide = (directory: string) =>
Effect.provide( Effect.provide(
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [ LayerNode.compile(Vcs.node, [
[ [
Location.node, Location.node,
Layer.succeed( Layer.succeed(
@@ -40,11 +37,6 @@ const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path))) ).pipe(Effect.flatMap((tmp) => f(tmp.path)))
const withHg = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
withTmp((directory) =>
Effect.promise(() => hg(directory, "init")).pipe(Effect.andThen(f(directory).pipe(provide(directory)))),
)
async function hg(directory: string, ...args: string[]) { async function hg(directory: string, ...args: string[]) {
await $`hg ${args}`.cwd(directory).env({ ...process.env, HGPLAIN: "1" }).quiet() await $`hg ${args}`.cwd(directory).env({ ...process.env, HGPLAIN: "1" }).quiet()
} }
@@ -56,9 +48,10 @@ async function commitAll(directory: string, message: string) {
describeHg("Vcs mercurial", () => { describeHg("Vcs mercurial", () => {
it.live("reports modified, missing, and untracked files", () => it.live("reports modified, missing, and untracked files", () =>
withHg((directory) => withTmp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.promise(async () => { yield* Effect.promise(async () => {
await hg(directory, "init")
await fs.writeFile(path.join(directory, "keep.txt"), "one\ntwo\n") await fs.writeFile(path.join(directory, "keep.txt"), "one\ntwo\n")
await fs.writeFile(path.join(directory, "gone.txt"), "bye\n") await fs.writeFile(path.join(directory, "gone.txt"), "bye\n")
await commitAll(directory, "initial") await commitAll(directory, "initial")
@@ -73,14 +66,15 @@ describeHg("Vcs mercurial", () => {
{ file: "keep.txt", additions: 1, deletions: 1, status: "modified" }, { file: "keep.txt", additions: 1, deletions: 1, status: "modified" },
{ file: "new.txt", additions: 2, deletions: 0, status: "added" }, { file: "new.txt", additions: 2, deletions: 0, status: "added" },
]) ])
}), }).pipe(provide(directory)),
), ),
) )
it.live("diffs the working copy with synthesized untracked and missing patches", () => it.live("diffs the working copy with synthesized untracked and missing patches", () =>
withHg((directory) => withTmp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.promise(async () => { yield* Effect.promise(async () => {
await hg(directory, "init")
await fs.writeFile(path.join(directory, "keep.txt"), "one\ntwo\n") await fs.writeFile(path.join(directory, "keep.txt"), "one\ntwo\n")
await fs.writeFile(path.join(directory, "gone.txt"), "bye\n") await fs.writeFile(path.join(directory, "gone.txt"), "bye\n")
await commitAll(directory, "initial") await commitAll(directory, "initial")
@@ -102,47 +96,16 @@ describeHg("Vcs mercurial", () => {
expect(diff[1].deletions).toBe(1) expect(diff[1].deletions).toBe(1)
expect(diff[2].patch).toContain("+hello") expect(diff[2].patch).toContain("+hello")
expect(diff[2].additions).toBe(1) expect(diff[2].additions).toBe(1)
}), }).pipe(provide(directory)),
),
)
it.live("caches branch info and publishes branch metadata changes", () =>
withHg((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial")
})
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
const updated = yield* bus.subscribe(VcsEvent.BranchUpdated).pipe(
Stream.take(1),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.promise(() => hg(directory, "branch", "-q", "feature"))
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
yield* bus.publish(FileSystem.Event.Changed, {
file: path.join(directory, ".hg", "branch"),
event: "change",
})
expect(yield* Fiber.join(updated)).toMatchObject({
_tag: "Some",
value: { location: { directory }, data: { branch: "feature" } },
})
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "default" } })
}),
), ),
) )
it.live("respects the context option", () => it.live("respects the context option", () =>
withHg((directory) => withTmp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {
const body = Array.from({ length: 20 }, (_, index) => `line-${index}`).join("\n") + "\n" const body = Array.from({ length: 20 }, (_, index) => `line-${index}`).join("\n") + "\n"
yield* Effect.promise(async () => { yield* Effect.promise(async () => {
await hg(directory, "init")
await fs.writeFile(path.join(directory, "file.txt"), body) await fs.writeFile(path.join(directory, "file.txt"), body)
await commitAll(directory, "initial") await commitAll(directory, "initial")
await fs.writeFile(path.join(directory, "file.txt"), body.replace("line-10", "changed")) await fs.writeFile(path.join(directory, "file.txt"), body.replace("line-10", "changed"))
@@ -154,14 +117,15 @@ describeHg("Vcs mercurial", () => {
const tight = yield* vcs.diff("working", { context: 1 }) const tight = yield* vcs.diff("working", { context: 1 })
expect(tight[0].patch).toContain("line-9") expect(tight[0].patch).toContain("line-9")
expect(tight[0].patch).not.toContain("line-0") expect(tight[0].patch).not.toContain("line-0")
}), }).pipe(provide(directory)),
), ),
) )
it.live("diffs before the first commit", () => it.live("diffs before the first commit", () =>
withHg((directory) => withTmp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.promise(async () => { yield* Effect.promise(async () => {
await hg(directory, "init")
await fs.writeFile(path.join(directory, "tracked.txt"), "a\nb\n") await fs.writeFile(path.join(directory, "tracked.txt"), "a\nb\n")
await hg(directory, "add", "-q", "tracked.txt") await hg(directory, "add", "-q", "tracked.txt")
await fs.writeFile(path.join(directory, "loose.txt"), "hello\n") await fs.writeFile(path.join(directory, "loose.txt"), "hello\n")
@@ -175,14 +139,15 @@ describeHg("Vcs mercurial", () => {
expect(diff).toHaveLength(2) expect(diff).toHaveLength(2)
expect(diff[0].patch).toContain("+hello") expect(diff[0].patch).toContain("+hello")
expect(diff[1].patch).toContain("+a") expect(diff[1].patch).toContain("+a")
}), }).pipe(provide(directory)),
), ),
) )
it.live("diffs a named branch against the default branch", () => it.live("diffs a named branch against the default branch", () =>
withHg((directory) => withTmp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.promise(async () => { yield* Effect.promise(async () => {
await hg(directory, "init")
await fs.writeFile(path.join(directory, "file.txt"), "one\n") await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial") await commitAll(directory, "initial")
}) })
@@ -199,7 +164,7 @@ describeHg("Vcs mercurial", () => {
{ file: "file.txt", status: "modified" }, { file: "file.txt", status: "modified" },
]) ])
expect(diff[0].patch).toContain("+two") expect(diff[0].patch).toContain("+two")
}), }).pipe(provide(directory)),
), ),
) )
}) })
+17 -54
View File
@@ -2,21 +2,18 @@ import { $ } from "bun"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { Effect, Fiber, Layer, Stream } from "effect" import { Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Vcs } from "@opencode-ai/core/vcs" import { Vcs } from "@opencode-ai/core/vcs"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect" import { it } from "./lib/effect"
const provide = (directory: string, input: { git?: boolean } = {}) => const provide = (directory: string, input: { git?: boolean } = {}) =>
Effect.provide( Effect.provide(
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [ LayerNode.compile(Vcs.node, [
[ [
Location.node, Location.node,
Layer.succeed( Layer.succeed(
@@ -38,13 +35,6 @@ const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path))) ).pipe(Effect.flatMap((tmp) => f(tmp.path)))
const withGit = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
withTmp((directory) =>
Effect.promise(() => initRepo(directory)).pipe(
Effect.andThen(f(directory).pipe(provide(directory, { git: true }))),
),
)
async function initRepo(directory: string) { async function initRepo(directory: string) {
await $`git init -b main`.cwd(directory).quiet() await $`git init -b main`.cwd(directory).quiet()
await $`git config core.fsmonitor false`.cwd(directory).quiet() await $`git config core.fsmonitor false`.cwd(directory).quiet()
@@ -63,7 +53,6 @@ describe("Vcs", () => {
withTmp((directory) => withTmp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {
const vcs = yield* Vcs.Service const vcs = yield* Vcs.Service
expect(yield* vcs.info()).toEqual({ branch: {} })
expect(yield* vcs.status()).toEqual([]) expect(yield* vcs.status()).toEqual([])
expect(yield* vcs.diff("working")).toEqual([]) expect(yield* vcs.diff("working")).toEqual([])
expect(yield* vcs.diff("branch")).toEqual([]) expect(yield* vcs.diff("branch")).toEqual([])
@@ -72,9 +61,10 @@ describe("Vcs", () => {
) )
it.live("reports modified, deleted, and untracked files", () => it.live("reports modified, deleted, and untracked files", () =>
withGit((directory) => withTmp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.promise(async () => { yield* Effect.promise(async () => {
await initRepo(directory)
await fs.writeFile(path.join(directory, "keep.txt"), "one\ntwo\n") await fs.writeFile(path.join(directory, "keep.txt"), "one\ntwo\n")
await fs.writeFile(path.join(directory, "gone.txt"), "bye\n") await fs.writeFile(path.join(directory, "gone.txt"), "bye\n")
await commitAll(directory, "initial") await commitAll(directory, "initial")
@@ -89,45 +79,15 @@ describe("Vcs", () => {
{ file: "keep.txt", additions: 1, deletions: 1, status: "modified" }, { file: "keep.txt", additions: 1, deletions: 1, status: "modified" },
{ file: "new.txt", additions: 2, deletions: 0, status: "added" }, { file: "new.txt", additions: 2, deletions: 0, status: "added" },
]) ])
}), }).pipe(provide(directory, { git: true })),
),
)
it.live("caches branch info and publishes HEAD changes", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial")
})
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
const updated = yield* bus.subscribe(VcsEvent.BranchUpdated).pipe(
Stream.take(1),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, "HEAD"), event: "change" })
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
expect(yield* Fiber.join(updated)).toMatchObject({
_tag: "Some",
value: { location: { directory }, data: { branch: "feature" } },
})
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
}),
), ),
) )
it.live("diffs the working copy against HEAD with patches", () => it.live("diffs the working copy against HEAD with patches", () =>
withGit((directory) => withTmp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.promise(async () => { yield* Effect.promise(async () => {
await initRepo(directory)
await fs.writeFile(path.join(directory, "keep.txt"), "one\ntwo\n") await fs.writeFile(path.join(directory, "keep.txt"), "one\ntwo\n")
await commitAll(directory, "initial") await commitAll(directory, "initial")
await fs.writeFile(path.join(directory, "keep.txt"), "one\nthree\n") await fs.writeFile(path.join(directory, "keep.txt"), "one\nthree\n")
@@ -145,15 +105,16 @@ describe("Vcs", () => {
expect(diff[0].deletions).toBe(1) expect(diff[0].deletions).toBe(1)
expect(diff[1].patch).toContain("+hello") expect(diff[1].patch).toContain("+hello")
expect(diff[1].additions).toBe(1) expect(diff[1].additions).toBe(1)
}), }).pipe(provide(directory, { git: true })),
), ),
) )
it.live("respects the context option", () => it.live("respects the context option", () =>
withGit((directory) => withTmp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {
const body = Array.from({ length: 20 }, (_, index) => `line-${index}`).join("\n") + "\n" const body = Array.from({ length: 20 }, (_, index) => `line-${index}`).join("\n") + "\n"
yield* Effect.promise(async () => { yield* Effect.promise(async () => {
await initRepo(directory)
await fs.writeFile(path.join(directory, "file.txt"), body) await fs.writeFile(path.join(directory, "file.txt"), body)
await commitAll(directory, "initial") await commitAll(directory, "initial")
await fs.writeFile(path.join(directory, "file.txt"), body.replace("line-10", "changed")) await fs.writeFile(path.join(directory, "file.txt"), body.replace("line-10", "changed"))
@@ -165,14 +126,15 @@ describe("Vcs", () => {
const tight = yield* vcs.diff("working", { context: 1 }) const tight = yield* vcs.diff("working", { context: 1 })
expect(tight[0].patch).toContain("line-9") expect(tight[0].patch).toContain("line-9")
expect(tight[0].patch).not.toContain("line-0") expect(tight[0].patch).not.toContain("line-0")
}), }).pipe(provide(directory, { git: true })),
), ),
) )
it.live("diffs before the first commit", () => it.live("diffs before the first commit", () =>
withGit((directory) => withTmp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.promise(async () => { yield* Effect.promise(async () => {
await initRepo(directory)
await fs.writeFile(path.join(directory, "new.txt"), "hello\n") await fs.writeFile(path.join(directory, "new.txt"), "hello\n")
}) })
const vcs = yield* Vcs.Service const vcs = yield* Vcs.Service
@@ -180,14 +142,15 @@ describe("Vcs", () => {
const diff = yield* vcs.diff("working") const diff = yield* vcs.diff("working")
expect(diff).toHaveLength(1) expect(diff).toHaveLength(1)
expect(diff[0].patch).toContain("+hello") expect(diff[0].patch).toContain("+hello")
}), }).pipe(provide(directory, { git: true })),
), ),
) )
it.live("diffs a feature branch against the default branch", () => it.live("diffs a feature branch against the default branch", () =>
withGit((directory) => withTmp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.promise(async () => { yield* Effect.promise(async () => {
await initRepo(directory)
await fs.writeFile(path.join(directory, "file.txt"), "one\n") await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial") await commitAll(directory, "initial")
}) })
@@ -204,7 +167,7 @@ describe("Vcs", () => {
{ file: "file.txt", status: "modified" }, { file: "file.txt", status: "modified" },
]) ])
expect(diff[0].patch).toContain("+two") expect(diff[0].patch).toContain("+two")
}), }).pipe(provide(directory, { git: true })),
), ),
) )
}) })
+4 -11
View File
@@ -1,9 +1,10 @@
import type { SessionApi } from "@opencode-ai/client/effect/api" import type { SessionApi } from "@opencode-ai/client/effect/api"
import type { Message, SystemPart } from "@opencode-ai/ai" import type { Message, SystemPart } from "@opencode-ai/ai"
import type { HttpRequest } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent" import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model" import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session" import type { Session } from "@opencode-ai/schema/session"
import type { Effect, JsonSchema } from "effect" import type { JsonSchema } from "effect"
import type { Hooks } from "./registration.js" import type { Hooks } from "./registration.js"
export interface SessionContext { export interface SessionContext {
@@ -15,23 +16,15 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }> tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
} }
export interface SessionHttp { export interface SessionRequest extends HttpRequest {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly agent: Agent.ID readonly agent: Agent.ID
readonly model: Model.Ref readonly model: Model.Ref
readonly use: (middleware: SessionHttpMiddleware) => Effect.Effect<void>
} }
export type SessionHttpHandler = (request: Request) => Effect.Effect<Response, Error>
export type SessionHttpMiddleware = (
request: Request,
next: SessionHttpHandler,
) => Effect.Effect<Response, Error>
export interface SessionHooks { export interface SessionHooks {
readonly context: SessionContext readonly context: SessionContext
readonly http: SessionHttp readonly request: SessionRequest
} }
export type SessionDomain = Pick< export type SessionDomain = Pick<
+2 -2
View File
@@ -17,7 +17,7 @@ export interface ToolHooks {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly agent: Agent.ID readonly agent: Agent.ID
readonly messageID: SessionMessage.ID readonly messageID: SessionMessage.ID
readonly id: Tool.CallID readonly callID: Tool.CallID
input: unknown input: unknown
} }
readonly "execute.after": { readonly "execute.after": {
@@ -25,7 +25,7 @@ export interface ToolHooks {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly agent: Agent.ID readonly agent: Agent.ID
readonly messageID: SessionMessage.ID readonly messageID: SessionMessage.ID
readonly id: Tool.CallID readonly callID: Tool.CallID
readonly input: unknown readonly input: unknown
} & ( } & (
| { | {
+3 -10
View File
@@ -1,5 +1,6 @@
import type { SessionApi } from "@opencode-ai/client/promise/api" import type { SessionApi } from "@opencode-ai/client/promise/api"
import type { Message, SystemPart } from "@opencode-ai/ai" import type { Message, SystemPart } from "@opencode-ai/ai"
import type { HttpRequest } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent" import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model" import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session" import type { Session } from "@opencode-ai/schema/session"
@@ -15,23 +16,15 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }> tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
} }
export interface SessionHttp { export interface SessionRequest extends HttpRequest {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly agent: Agent.ID readonly agent: Agent.ID
readonly model: Model.Ref readonly model: Model.Ref
readonly use: (middleware: SessionHttpMiddleware) => void
} }
export type SessionHttpHandler = (request: Request) => Promise<Response>
export type SessionHttpMiddleware = (
request: Request,
next: SessionHttpHandler,
) => Promise<Response> | Response
export interface SessionHooks { export interface SessionHooks {
readonly context: SessionContext readonly context: SessionContext
readonly http: SessionHttp readonly request: SessionRequest
} }
export type SessionDomain = Pick< export type SessionDomain = Pick<
+2 -2
View File
@@ -34,7 +34,7 @@ interface ToolHooks {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly agent: Agent.ID readonly agent: Agent.ID
readonly messageID: SessionMessage.ID readonly messageID: SessionMessage.ID
readonly id: Tool.CallID readonly callID: Tool.CallID
input: unknown input: unknown
} }
readonly "execute.after": { readonly "execute.after": {
@@ -42,7 +42,7 @@ interface ToolHooks {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly agent: Agent.ID readonly agent: Agent.ID
readonly messageID: SessionMessage.ID readonly messageID: SessionMessage.ID
readonly id: Tool.CallID readonly callID: Tool.CallID
readonly input: unknown readonly input: unknown
} & ( } & (
| { | {
-6
View File
@@ -19,7 +19,6 @@ import type {
SessionPendingInfo, SessionPendingInfo,
ShellInfo, ShellInfo,
SkillInfo, SkillInfo,
VcsInfo,
} from "@opencode-ai/client" } from "@opencode-ai/client"
import type { ResolvedTheme } from "@opencode-ai/theme/tui" import type { ResolvedTheme } from "@opencode-ai/theme/tui"
import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core" import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core"
@@ -114,11 +113,6 @@ export interface Data {
default(): LocationRef default(): LocationRef
sync(location?: LocationRef): Promise<void> sync(location?: LocationRef): Promise<void>
invalidate(location?: LocationRef): void invalidate(location?: LocationRef): void
readonly vcs: {
info(location?: LocationRef): VcsInfo | undefined
sync(location?: LocationRef): Promise<void>
invalidate(location?: LocationRef): void
}
readonly agent: LocationCollection<AgentInfo> readonly agent: LocationCollection<AgentInfo>
readonly command: LocationCollection<CommandInfo> readonly command: LocationCollection<CommandInfo>
readonly integration: LocationCollection<IntegrationInfo> readonly integration: LocationCollection<IntegrationInfo>
+6 -153
View File
@@ -851,16 +851,6 @@
} }
] ]
}, },
"title": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"agent": { "agent": {
"anyOf": [ "anyOf": [
{ {
@@ -11311,104 +11301,6 @@
} }
} }
}, },
"/api/vcs": {
"get": {
"tags": [
"vcs"
],
"operationId": "v2.vcs.get",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.Info"
},
"data": {
"$ref": "#/components/schemas/Vcs.Info"
}
},
"required": [
"location",
"data"
],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestError"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedError"
}
}
}
}
},
"description": "Get current and default branch information for the requested location.",
"summary": "VCS info"
}
},
"/api/vcs/status": { "/api/vcs/status": {
"get": { "get": {
"tags": [ "tags": [
@@ -12140,15 +12032,11 @@
}, },
"directory": { "directory": {
"type": "string" "type": "string"
},
"canonical": {
"type": "string"
} }
}, },
"required": [ "required": [
"id", "id",
"directory", "directory"
"canonical"
], ],
"additionalProperties": false "additionalProperties": false
} }
@@ -12610,6 +12498,7 @@
"cost", "cost",
"tokens", "tokens",
"time", "time",
"title",
"location" "location"
], ],
"additionalProperties": false "additionalProperties": false
@@ -13948,15 +13837,6 @@
}, },
"message": { "message": {
"type": "string" "type": "string"
},
"status": {
"type": "integer",
"allOf": [
{
"minimum": 100,
"maximum": 599
}
]
} }
}, },
"required": [ "required": [
@@ -20885,7 +20765,7 @@
"id": { "id": {
"type": "string" "type": "string"
}, },
"canonical": { "worktree": {
"type": "string" "type": "string"
}, },
"vcs": { "vcs": {
@@ -20912,7 +20792,7 @@
}, },
"required": [ "required": [
"id", "id",
"canonical", "worktree",
"time", "time",
"sandboxes" "sandboxes"
], ],
@@ -20926,15 +20806,11 @@
}, },
"directory": { "directory": {
"type": "string" "type": "string"
},
"canonical": {
"type": "string"
} }
}, },
"required": [ "required": [
"id", "id",
"directory", "directory"
"canonical"
], ],
"additionalProperties": false "additionalProperties": false
}, },
@@ -22569,6 +22445,7 @@
"slug", "slug",
"projectID", "projectID",
"directory", "directory",
"title",
"version", "version",
"time" "time"
], ],
@@ -29250,30 +29127,6 @@
], ],
"additionalProperties": false "additionalProperties": false
}, },
"Vcs.Branch": {
"type": "object",
"properties": {
"current": {
"type": "string"
},
"default": {
"type": "string"
}
},
"additionalProperties": false
},
"Vcs.Info": {
"type": "object",
"properties": {
"branch": {
"$ref": "#/components/schemas/Vcs.Branch"
}
},
"required": [
"branch"
],
"additionalProperties": false
},
"Vcs.FileStatus": { "Vcs.FileStatus": {
"type": "object", "type": "object",
"properties": { "properties": {
-14
View File
@@ -13,20 +13,6 @@ const DiffQuery = Schema.Struct({
}) })
export const VcsGroup = HttpApiGroup.make("server.vcs") export const VcsGroup = HttpApiGroup.make("server.vcs")
.add(
HttpApiEndpoint.get("vcs.get", "/api/vcs", {
query: LocationQuery,
success: Location.response(Vcs.Info),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.vcs.get",
summary: "VCS info",
description: "Get current and default branch information for the requested location.",
}),
),
)
.add( .add(
HttpApiEndpoint.get("vcs.status", "/api/vcs/status", { HttpApiEndpoint.get("vcs.status", "/api/vcs/status", {
query: LocationQuery, query: LocationQuery,
-3
View File
@@ -46,9 +46,6 @@ export const Info = Schema.Struct({
permissions: [ permissions: [
{ action: "*", resource: "*", effect: "allow" }, { action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "ask" }, { action: "external_directory", resource: "*", effect: "ask" },
{ action: "read", resource: "*.env", effect: "ask" },
{ action: "read", resource: "*.env.*", effect: "ask" },
{ action: "read", resource: "*.env.example", effect: "allow" },
], ],
}) satisfies Info, }) satisfies Info,
})), })),
+1 -1
View File
@@ -17,7 +17,7 @@ export const Source = Schema.Union([
Schema.Struct({ Schema.Struct({
type: Schema.Literal("tool"), type: Schema.Literal("tool"),
messageID: Schema.String, messageID: Schema.String,
id: Schema.String, callID: Schema.String,
}), }),
]).annotate({ identifier: "Permission.Source" }) ]).annotate({ identifier: "Permission.Source" })
export type Source = typeof Source.Type 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({ export const Tool = Schema.Struct({
messageID: Schema.String, messageID: Schema.String,
id: Schema.String, callID: Schema.String,
}).annotate({ identifier: "Question.Tool" }) }).annotate({ identifier: "Question.Tool" })
export interface Tool extends Schema.Schema.Type<typeof Tool> {} export interface Tool extends Schema.Schema.Type<typeof Tool> {}

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