Compare commits

..

10 Commits

Author SHA1 Message Date
Kit Langton a841e828ce fix(tui): wait for session model hydration 2026-08-04 16:17:33 -04:00
Aiden Cline b9c118e0fe chore(core): drop unreachable AI SDK provider packages (#40504) 2026-08-04 14:37:56 -05:00
opencode-agent[bot] d1c9e39978 fix(tui): update tab titles immediately (#40318)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-08-04 14:23:29 -04:00
Aiden Cline 0b1ec457dc fix(tui): show branch beside directory (#40500) 2026-08-04 12:26:18 -05:00
Aiden Cline 9bd99b4c91 feat(vcs): publish branch updates (#40371) 2026-08-04 11:52:17 -05:00
opencode-agent[bot] 94fcb119a3 fix(core): avoid implicit output limits (#40488)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 11:09:23 -05:00
Aiden Cline aa820db18d feat(plugin): add session HTTP middleware (#40327) 2026-08-04 09:15:52 -05:00
Aiden Cline 42e8d11552 feat(vcs): expose branch info (#40368) 2026-08-03 23:18:13 -05:00
Aiden Cline 28d784b83a fix(core): execute tools renamed by context hooks (#40359) 2026-08-03 22:57:06 -05:00
Aiden Cline a4ad17347f fix(core): apply safe defaults to all agents (#40316) 2026-08-03 17:56:04 -05:00
72 changed files with 1636 additions and 1030 deletions
+20 -33
View File
@@ -88,6 +88,7 @@
"@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",
@@ -284,13 +285,9 @@
"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": {
@@ -298,7 +295,6 @@
"@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:",
}, },
}, },
@@ -361,18 +357,15 @@
"@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:",
@@ -386,7 +379,6 @@
"@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:",
@@ -911,7 +903,6 @@
"@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:",
@@ -1038,7 +1029,6 @@
"@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",
@@ -1084,17 +1074,16 @@
"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:",
@@ -1220,9 +1209,9 @@
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="], "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="], "@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="],
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="], "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/perplexity": ["@ai-sdk/perplexity@3.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-dXzrVsLR5f6tr+U04jq4AXoRroGFBTvODnLgss0SWbzNjGGQg3XqtQ9j7rCLo6o8qbYGuAHvqUrIpUCuiscuFg=="], "@ai-sdk/perplexity": ["@ai-sdk/perplexity@3.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-dXzrVsLR5f6tr+U04jq4AXoRroGFBTvODnLgss0SWbzNjGGQg3XqtQ9j7rCLo6o8qbYGuAHvqUrIpUCuiscuFg=="],
@@ -4944,7 +4933,7 @@
"open": ["open@10.1.2", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="], "open": ["open@10.1.2", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="],
"openai": ["openai@5.11.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-+AuTc5pVjlnTuA9zvn8rA/k+1RluPIx9AD4eDcnutv6JNwHHZxIhkFy+tmMKCvmMFDQzfA/r1ujvPWB19DQkYg=="], "openai": ["openai@6.49.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "ws", "zod"] }, "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
@@ -5962,12 +5951,8 @@
"@actions/http-client/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], "@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=="],
@@ -5982,6 +5967,8 @@
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], "@ai-sdk/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=="],
@@ -5990,6 +5977,8 @@
"@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="], "@ai-sdk/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=="],
@@ -6024,14 +6013,18 @@
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="], "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/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=="],
@@ -6350,10 +6343,6 @@
"@opencode-ai/console-mail/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="], "@opencode-ai/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=="],
@@ -6578,6 +6567,8 @@
"babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], "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=="],
@@ -6714,8 +6705,6 @@
"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=="],
@@ -7380,10 +7369,6 @@
"@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=="],
@@ -7620,6 +7605,8 @@
"babel-plugin-module-resolver/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], "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,7 +161,6 @@
"@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",
+1 -3
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, HttpRequestTransform, Transport, TransportRuntime } from "./transport" import type { HttpMiddleware, 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,7 +155,6 @@ export interface Interface {
} }
export interface StreamOptions { export interface StreamOptions {
readonly transform?: HttpRequestTransform
readonly http?: HttpMiddleware readonly http?: HttpMiddleware
} }
@@ -308,7 +307,6 @@ 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: "HTTP transport failed" }) return transportError({ message: error instanceof Error ? error.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") {
+1 -8
View File
@@ -23,11 +23,4 @@ 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 { export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport"
HttpHandler,
HttpMiddleware,
HttpRequest,
HttpRequestTransform,
Transport as TransportDef,
TransportRuntime,
} from "./transport"
+3 -5
View File
@@ -75,12 +75,10 @@ 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: transformed.url, url: parts.url,
body: transformed.body ?? "", body: parts.bodyText,
headers: Headers.fromInput(transformed.headers), headers: parts.headers,
}) })
return { return {
request, request,
-10
View File
@@ -10,15 +10,6 @@ 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>
@@ -32,7 +23,6 @@ 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
} }
+12
View File
@@ -67,6 +67,18 @@ 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
+11 -4
View File
@@ -1502,18 +1502,25 @@ 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: ReadonlyArray<Vcs.FileStatus> } export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
export type VcsStatusOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E> export type VcsGetOperation<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_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> } export type Endpoint25_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
export type VcsDiffOperation<E = never> = (input: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E> export type VcsDiffOperation<E = never> = (input: Endpoint25_2Input) => Effect.Effect<Endpoint25_2Output, 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>
} }
+14 -3
View File
@@ -210,6 +210,8 @@ 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,
@@ -1182,17 +1184,26 @@ 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_1 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_1Input) => const Endpoint25_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_2Input) =>
preserveEffect<Endpoint25_1Output>()( preserveEffect<Endpoint25_2Output>()(
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"]) => ({ status: Endpoint25_0(raw), diff: Endpoint25_1(raw) }) const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({
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)))
@@ -202,6 +202,8 @@ import type {
ProjectCopyRemoveOutput, ProjectCopyRemoveOutput,
ProjectCopyRefreshInput, ProjectCopyRefreshInput,
ProjectCopyRefreshOutput, ProjectCopyRefreshOutput,
VcsGetInput,
VcsGetOutput,
VcsStatusInput, VcsStatusInput,
VcsStatusOutput, VcsStatusOutput,
VcsDiffInput, VcsDiffInput,
@@ -1702,6 +1704,18 @@ 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>(
{ {
@@ -530,6 +530,8 @@ 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
@@ -1745,6 +1747,8 @@ 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 = {
@@ -4902,6 +4906,17 @@ 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
+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(["status", "diff"]) expect(Object.keys(client.vcs)).toEqual(["get", "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"])
-5
View File
@@ -12,18 +12,13 @@
"@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,18 +78,15 @@
"@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:",
@@ -106,7 +103,6 @@
"@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:",
+20 -2
View File
@@ -1,11 +1,16 @@
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
@@ -51,6 +56,13 @@ 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() }),
@@ -61,7 +73,13 @@ const layer = Layer.effect(
draft.default = id draft.default = id
}, },
update: (id, fn) => { update: (id, fn) => {
const current = draft.agents.get(id) ?? (Info.default(id) as Types.DeepMutable<Info>) const defaults = Info.default(id)
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
@@ -114,4 +132,4 @@ const layer = Layer.effect(
}), }),
) )
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node] }) export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node, Global.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 ?? request.model.route.defaults.limits?.output, maxOutputTokens: request.generation?.maxTokens,
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,6 +71,14 @@ 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 })),
+4 -1
View File
@@ -2,6 +2,7 @@ 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"
@@ -15,6 +16,7 @@ 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) =>
@@ -31,6 +33,7 @@ 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"),
), ),
@@ -58,4 +61,4 @@ const layer = Layer.effect(
}), }),
) )
export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] }) export const node = makeLocationNode({ service: Service, layer, deps: [Global.node, Location.node] })
+15 -50
View File
@@ -1,17 +1,10 @@
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:
@@ -100,38 +93,12 @@ 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( item.permissions.push({ action: "question", resource: "*", effect: "allow" })
...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) => {
@@ -139,18 +106,8 @@ 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(
...Permission.merge(defaults, [
{ action: "question", resource: "*", effect: "allow" }, { 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: "*", 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",
},
]),
) )
}) })
@@ -159,10 +116,16 @@ 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(...Permission.merge(defaults, [{ action: "subagent", resource: "*", effect: "deny" }])) item.permissions.push(
{ 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.'
@@ -170,7 +133,6 @@ 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" },
@@ -178,9 +140,12 @@ 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" },
], ],
readonlyExternalDirectory, [{ action: "external_directory", resource: "*", effect: "ask" }, ...externalDirectories],
), ),
) )
}) })
@@ -190,7 +155,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(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) item.permissions.push({ action: "*", resource: "*", effect: "deny" })
}) })
draft.update(Agent.ID.make("title"), (item) => { draft.update(Agent.ID.make("title"), (item) => {
@@ -198,7 +163,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(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) item.permissions.push({ action: "*", resource: "*", effect: "deny" })
}) })
draft.update(Agent.ID.make("summary"), (item) => { draft.update(Agent.ID.make("summary"), (item) => {
@@ -206,7 +171,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(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) item.permissions.push({ action: "*", resource: "*", effect: "deny" })
}) })
}) })
}), }),
+61 -3
View File
@@ -2,6 +2,7 @@ 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"
@@ -57,6 +58,62 @@ 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,
@@ -194,7 +251,9 @@ export function fromPromise(plugin: Plugin) {
), ),
), ),
refresh: refresh:
refresh === undefined ? undefined : (credential) => Effect.promise(() => refresh(credential)), refresh === undefined
? undefined
: (credential) => Effect.promise(() => refresh(credential)),
}) })
}, },
remove: draft.method.remove, remove: draft.method.remove,
@@ -263,8 +322,7 @@ export function fromPromise(plugin: Plugin) {
), ),
}, },
session: { session: {
hook: (name, callback) => hook: sessionHook,
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,7 +11,6 @@ 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"
@@ -46,7 +45,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
GatewayPlugin, GatewayPlugin,
GithubCopilotPlugin, GithubCopilotPlugin,
GitLabPlugin, GitLabPlugin,
GooglePlugin,
GoogleVertexAnthropicPlugin, GoogleVertexAnthropicPlugin,
GoogleVertexPlugin, GoogleVertexPlugin,
GroqPlugin, GroqPlugin,
@@ -1,16 +0,0 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
export const GooglePlugin = define({
id: "opencode.provider.google",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/google") return
const mod = yield* Effect.promise(() => import("@ai-sdk/google"))
evt.sdk = mod.createGoogleGenerativeAI(evt.options)
}),
)
}),
})
+8 -9
View File
@@ -225,15 +225,14 @@ export const OpenAIPlugin = define({
}) })
} }
}) })
yield* ctx.session.hook("request", (evt) => yield* ctx.session.hook("http", (evt) =>
Effect.sync(() => { evt.use((request, next) => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return next(request)
const url = new URL(evt.url) const url = new URL(request.url)
if (url.origin === "https://api.openai.com") { request.headers.set("originator", "opencode")
evt.url = `${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}` request.headers.set("session-id", evt.sessionID)
} if (url.origin !== "https://api.openai.com") return next(request)
evt.headers.originator = "opencode" return next(new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, request))
evt.headers["session-id"] = evt.sessionID
}), }),
) )
@@ -23,13 +23,5 @@ export const OpenRouterPlugin = define({
} }
} }
}) })
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@openrouter/ai-sdk-provider") return
const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider"))
evt.sdk = mod.createOpenRouter(evt.options)
}),
)
}), }),
}) })
-16
View File
@@ -6,7 +6,6 @@ import { App } from "../../app"
import { Credential } from "../../credential" import { 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"
@@ -162,21 +161,6 @@ 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)
}),
)
}), }),
}) })
+72 -39
View File
@@ -2,9 +2,11 @@ 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 } from "effect" import { Cause, Config, Context, Effect, Layer, Result, Stream } 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"
@@ -48,9 +50,7 @@ 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: ( readonly executeTool: (input: Parameters<Tool.Snapshot["execute"]>[0]) => Effect.Effect<Tool.Result, ExecuteError>
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,8 +137,7 @@ 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) if (item.type !== "file" || !isImage(item.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET) return item
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 }
}), }),
@@ -189,55 +188,87 @@ 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 toolDefinitions = tools.definitions const registry = new Map(tools.definitions.map((tool) => [tool.name, tool]))
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool])) // The definition objects we hand to hooks, mapped back to their tools. Hooks rename a
// Hooks may reshape available definitions but cannot advertise tools omitted by permissions or the Step limit. // tool by moving its definition to a new key; recognizing the object recovers the tool.
const contextEvent = yield* hooks.trigger("session", "context", { const given = new Map(
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( tools: Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition])),
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: contextEvent.system, system: context.system,
messages: boundImages(unsupportedParts(contextEvent.messages, resolved.capabilities)), messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
tools: hookedTools, tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
toolChoice: stepLimitReached ? "none" : undefined, toolChoice: stepLimitReached ? "none" : undefined,
}) })
const options: StreamOptions = { const options: StreamOptions = {
transform: (request) => http: (request, handler) =>
hooks Effect.gen(function* () {
.trigger("session", "request", { let latest = 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,
...request, use: (item) =>
})
.pipe(
Effect.tap((event) =>
Effect.sync(() => { Effect.sync(() => {
request.url = event.url middlewares.push(item)
request.headers = event.headers
request.body = event.body
}), }),
), })
Effect.asVoid, const send = (input: Request) =>
), Effect.gen(function* () {
let sent = HttpClientRequest.fromWeb(input)
if (input.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => input.clone().arrayBuffer())),
input.headers.get("content-type") ?? undefined,
)
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)
@@ -256,13 +287,15 @@ export const layer = Layer.effect(
}), }),
) )
} }
const executeTool: Prepared["executeTool"] = (executeInput) => { const executeTool: Prepared["executeTool"] = (input) => {
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" })
if (toolsByName.has(executeInput.call.name) && !Object.hasOwn(contextEvent.tools, executeInput.call.name)) const tool = hooked.get(input.call.name)
return new Tool.Error({ message: `Tool is not available for this request: ${executeInput.call.name}` }) // A registered tool absent from the hooked set was removed or renamed by a hook.
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(executeInput) .execute(tool ? { ...input, call: { ...input.call, name: tool.name } } : input)
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline))) .pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
} }
return { return {
+37 -4
View File
@@ -1,22 +1,27 @@
export * as Vcs from "./vcs" export * as Vcs from "./vcs"
import { Context, Effect, Layer } from "effect" import path from "path"
import { Context, Effect, Layer, Stream } from "effect"
import { FileDiff } from "@opencode-ai/schema/file-diff" import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileStatus, Mode } from "@opencode-ai/schema/vcs" import { FileSystem } from "@opencode-ai/schema/filesystem"
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, Mode } export { FileStatus, Info, 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[]>
} }
@@ -38,8 +43,36 @@ 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()
@@ -55,5 +88,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], deps: [AppProcess.node, FSUtil.node, Location.node, Bus.node],
}) })
+7 -1
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, Mode } from "@opencode-ai/schema/vcs" import { FileStatus, Info, 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,6 +20,12 @@ 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
+4 -1
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, Mode } from "@opencode-ai/schema/vcs" import { FileStatus, Info, 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,6 +73,9 @@ 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.
+36 -12
View File
@@ -1,3 +1,4 @@
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"
@@ -9,15 +10,19 @@ 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>,
) )
@@ -120,25 +125,35 @@ 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, () => {}))
expect(yield* agent.get(id)).toEqual(Agent.Info.default(id)) const info = yield* agent.get(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("does not ambiently opt built-in agents into bash", () => it.effect("applies managed external directories without opting 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()
@@ -152,6 +167,20 @@ 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)
} }
@@ -165,11 +194,6 @@ 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,6 +98,23 @@ 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
+14 -9
View File
@@ -20,10 +20,13 @@ 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 = [ const defaultPermissions = (global: Global.Interface): Permission.Ruleset => [
{ action: "*", resource: "*", effect: "allow" }, ...Agent.Info.default(Agent.ID.make("test")).permissions,
{ action: "external_directory", resource: "*", effect: "ask" }, { action: "external_directory", resource: path.join(global.data, "shell", "*", "*"), effect: "allow" },
] satisfies Permission.Ruleset { action: "external_directory", resource: path.join(global.data, "tool-output", "*"), effect: "allow" },
{ 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()
@@ -58,6 +61,7 @@ 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) => {
@@ -110,7 +114,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, ...defaultPermissions(global),
{ 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" },
@@ -128,7 +132,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, ...defaultPermissions(global),
{ 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" },
@@ -136,7 +140,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, ...defaultPermissions(global),
{ 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" },
@@ -270,6 +274,7 @@ 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",
@@ -287,13 +292,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, { action: "edit", resource: "*", effect: "deny" }], permissions: [...defaultPermissions(global), { 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, { action: "edit", resource: "*", effect: "deny" }], permissions: [...defaultPermissions(global), { 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()
+26 -7
View File
@@ -153,12 +153,16 @@ 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?: { git?: boolean; init?: (directory: string) => Promise<void> }, options?: { vcs?: "git" | "hg"; 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?.git) return { tmp, vcs: undefined } if (options?.vcs === "hg") {
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()
@@ -292,7 +296,7 @@ describeWatcher("LocationWatcher", () => {
}) })
} }
}), }),
{ git: true }, { vcs: "git" },
), ),
) )
@@ -322,7 +326,7 @@ describeWatcher("LocationWatcher", () => {
}), }),
) )
}), }),
{ git: true }, { vcs: "git" },
), ),
) )
@@ -359,7 +363,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),
) )
}), }),
{ git: true }, { vcs: "git" },
), ),
) )
@@ -376,7 +380,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 })
}), }),
{ git: true }, { vcs: "git" },
), ),
) )
@@ -401,7 +405,7 @@ describeWatcher("LocationWatcher", () => {
).toEqual({ file: path.join(actual, "HEAD"), event: "change" }) ).toEqual({ file: path.join(actual, "HEAD"), event: "change" })
}), }),
{ {
git: true, vcs: "git",
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)
@@ -411,4 +415,19 @@ 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 under the system temp directory", () => { test("tmp path is the canonical system temp directory", async () => {
expect(Global.Path.tmp).toBe(path.join(os.tmpdir(), "opencode")) expect(Global.Path.tmp).toBe(await fs.realpath(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" })], [Global.node, Global.layerWith({ config: "/global", tmp: "/temporary" })],
]), ]),
) )
@@ -49,6 +49,7 @@ 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)}`,
+106 -7
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, Effect, Schema } from "effect" import { DateTime, Deferred, Effect, Fiber, 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 } from "@opencode-ai/plugin/effect/session" import type { SessionHooks, 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"
import { host as testHost } from "./host" import { host as testHost } from "./host"
@@ -148,7 +148,9 @@ 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("Agent not found: missing") await expect(ctx.agent.get({ agentID: Agent.ID.make("missing") })).rejects.toThrow(
"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",
@@ -221,6 +223,105 @@ 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
@@ -315,8 +416,7 @@ 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", name: "hello",
options: { codemode: false }, options: { codemode: false },
description: "Hello", description: "Hello",
@@ -326,8 +426,7 @@ describe("fromPromise", () => {
await context.progress({ phase: "greeting" }) await context.progress({ phase: "greeting" })
return { output: `Hello, ${name}!` } return { output: `Hello, ${name}!` }
}, },
}, })
)
}) })
}, },
}) })
@@ -1,103 +0,0 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* GooglePlugin.effect(host)
})
describe("GooglePlugin", () => {
it.effect("creates a Google Generative AI SDK for @ai-sdk/google using the provider ID as SDK name", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-google"), Model.ID.make("gemini")),
modelID: Model.ID.make("gemini"),
package: "aisdk:@ai-sdk/google",
}),
package: "@ai-sdk/google",
options: { name: "custom-google", apiKey: "test" },
})
expect(result.sdk).toBeDefined()
expect(result.sdk?.languageModel("gemini").provider).toBe("custom-google")
}),
)
it.effect("ignores non-Google SDK packages", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("google"), Model.ID.make("gemini")),
modelID: Model.ID.make("gemini"),
package: "aisdk:@ai-sdk/google",
}),
package: "@ai-sdk/google-vertex",
options: { name: "google" },
})
expect(result.sdk).toBeUndefined()
}),
)
it.effect("uses default languageModel loading with provider ID parity", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const sdkEvent = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-google"), Model.ID.make("alias")),
modelID: Model.ID.make("gemini-api"),
package: "aisdk:@ai-sdk/google",
}),
package: "@ai-sdk/google",
options: { name: "custom-google", apiKey: "test" },
})
const result = yield* aisdk.runLanguage({
model: sdkEvent.model,
sdk: sdkEvent.sdk,
options: sdkEvent.options,
})
const language = result.language ?? result.sdk.languageModel(result.model.modelID ?? result.model.id)
expect(language.modelId).toBe("gemini-api")
expect(language.provider).toBe("custom-google")
}),
)
it.effect("wraps AI SDK language models for the native runner", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* addPlugin()
const resolved = yield* aisdk.model(
Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-google"), Model.ID.make("alias")),
modelID: Model.ID.make("gemini-api"),
package: "aisdk:@ai-sdk/google",
settings: { apiKey: "test" },
}),
)
expect(String(resolved.id)).toBe("gemini-api")
expect(String(resolved.provider)).toBe("custom-google")
expect(resolved.route.id).toBe("ai-sdk:@ai-sdk/google")
}),
)
})
@@ -12,6 +12,7 @@ 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"
@@ -29,6 +30,29 @@ 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* () {
@@ -100,33 +124,9 @@ describe("OpenAIPlugin", () => {
}) })
yield* addPlugin() yield* addPlugin()
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", { const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
sessionID: Session.ID.make("ses_test"), const custom = yield* http(Provider.ID.make("custom-openai"), "https://custom.example/v1/responses")
agent: Agent.ID.make("build"), const proxy = yield* http(Provider.ID.openai, "https://proxy.example/v1/responses?region=us")
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).toEqual({}) expect(custom.headers).not.toHaveProperty("originator")
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,21 +184,13 @@ describe("OpenAIPlugin", () => {
}) })
yield* addPlugin() yield* addPlugin()
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", { const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
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).toEqual({}) expect(request.headers).not.toHaveProperty("originator")
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,4 +1,3 @@
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"
@@ -15,7 +14,6 @@ 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)
}) })
@@ -46,36 +44,6 @@ describe("OpenRouterPlugin", () => {
}), }),
) )
it.effect("creates an SDK only for the OpenRouter package", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const ignored = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.openrouter, Model.ID.make("openai/gpt-5")),
modelID: Model.ID.make("openai/gpt-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@ai-sdk/openai-compatible",
options: { name: "openrouter" },
})
expect(ignored.sdk).toBeUndefined()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom"), Model.ID.make("openai/gpt-5")),
modelID: Model.ID.make("openai/gpt-5"),
package: Provider.aisdk("test-provider"),
}),
package: "@openrouter/ai-sdk-provider",
options: { name: "custom" },
})
expect(result.sdk).toBeDefined()
}),
)
it.effect("filters OpenRouter's gpt-5 chat alias", () => 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,13 +1,9 @@
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"
@@ -19,19 +15,6 @@ 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* () {
@@ -54,95 +37,4 @@ describe("XAIPlugin", () => {
]) ])
}), }),
) )
it.effect("creates an xAI SDK only for @ai-sdk/xai", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* addPlugin()
const ignored = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("xai"), Model.ID.make("grok-4")),
modelID: Model.ID.make("grok-4"),
package: "aisdk:@ai-sdk/xai",
}),
package: "@ai-sdk/openai-compatible",
options: {},
})
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("xai"), Model.ID.make("grok-4")),
modelID: Model.ID.make("grok-4"),
package: "aisdk:@ai-sdk/xai",
}),
package: "@ai-sdk/xai",
options: {},
})
expect(ignored.sdk).toBeUndefined()
expect(typeof result.sdk?.responses).toBe("function")
}),
)
it.effect("creates xAI SDKs for custom provider IDs", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-xai"), Model.ID.make("grok-4")),
modelID: Model.ID.make("grok-4"),
package: "aisdk:@ai-sdk/xai",
}),
package: "@ai-sdk/xai",
options: {},
})
expect(result.sdk.responses("grok-4").provider).toBe("xai.responses")
}),
)
it.effect("uses responses with the model modelID for xAI language models", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("xai"), Model.ID.make("alias")),
modelID: Model.ID.make("grok-4"),
package: "aisdk:@ai-sdk/xai",
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
expect(calls).toEqual(["responses:grok-4"])
expect(result.language).toBeDefined()
}),
)
it.effect("ignores non-xAI providers", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: Model.Info.make({
...Model.Info.default(Provider.ID.openai, Model.ID.make("grok-4")),
modelID: Model.ID.make("grok-4"),
package: "aisdk:@ai-sdk/xai",
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
expect(calls).toEqual([])
expect(result.language).toBeUndefined()
}),
)
}) })
@@ -1,32 +0,0 @@
import { createXai } from "@ai-sdk/xai"
import { expect, test } from "bun:test"
test("xAI Responses sends promptCacheKey as prompt_cache_key", async () => {
let body: Record<string, unknown> | undefined
const mockFetch = Object.assign(
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
body = JSON.parse(String(init?.body))
return Response.json({
id: "response-1",
created_at: 0,
model: "grok-4",
object: "response",
output: [],
usage: { input_tokens: 1, output_tokens: 0 },
status: "completed",
})
},
{ preconnect: fetch.preconnect },
)
const model = createXai({
apiKey: "test",
fetch: mockFetch,
}).responses("grok-4")
await model.doGenerate({
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
providerOptions: { xai: { promptCacheKey: "session-123" } },
})
expect(body?.prompt_cache_key).toBe("session-123")
})
@@ -41,6 +41,7 @@ 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"
@@ -104,9 +105,10 @@ const promptCatalog = Layer.mock(Catalog.Service, {
small: () => Effect.succeed(undefined), small: () => Effect.succeed(undefined),
}, },
}) })
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ const runnerLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
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],
@@ -117,8 +119,9 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[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>) =>
Layer.effect(
SessionExecution.Service, SessionExecution.Service,
Effect.gen(function* () { Effect.gen(function* () {
const sessionRunner = yield* SessionRunner.Service const sessionRunner = yield* SessionRunner.Service
@@ -133,8 +136,8 @@ const execution = Layer.effect(
awaitIdle: coordinator.awaitIdle, awaitIdle: coordinator.awaitIdle,
}) })
}), }),
).pipe(Layer.provide(runnerLayer)) ).pipe(Layer.provide(runnerLayer(llmClient)))
const it = testEffect( const testLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
AppNodeBuilder.build( AppNodeBuilder.build(
LayerNode.group([ LayerNode.group([
Database.node, Database.node,
@@ -156,7 +159,7 @@ const it = testEffect(
Session.node, Session.node,
]), ]),
[ [
[LayerNodePlatform.llmClient, client], [LayerNodePlatform.llmClient, llmClient],
[Permission.node, permission], [Permission.node, permission],
[Catalog.node, promptCatalog], [Catalog.node, promptCatalog],
[SessionRunnerModel.node, models], [SessionRunnerModel.node, models],
@@ -168,10 +171,10 @@ const it = testEffect(
[Config.node, config], [Config.node, config],
[Snapshot.node, Snapshot.noopLayer], [Snapshot.node, Snapshot.noopLayer],
[PluginSupervisor.node, pluginSupervisor], [PluginSupervisor.node, pluginSupervisor],
[SessionExecution.node, execution], [SessionExecution.node, execution(llmClient)],
], ],
), )
) 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", () => {
@@ -247,3 +250,90 @@ 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])
}),
)
})
+21
View File
@@ -887,6 +887,27 @@ 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
+52 -17
View File
@@ -2,11 +2,14 @@ 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, Layer } from "effect" import { Effect, Fiber, Layer, Stream } 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"
@@ -15,7 +18,7 @@ const describeHg = Bun.which("hg") ? describe : describe.skip
const provide = (directory: string) => const provide = (directory: string) =>
Effect.provide( Effect.provide(
LayerNode.compile(Vcs.node, [ LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [
[ [
Location.node, Location.node,
Layer.succeed( Layer.succeed(
@@ -37,6 +40,11 @@ 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()
} }
@@ -48,10 +56,9 @@ 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", () =>
withTmp((directory) => withHg((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")
@@ -66,15 +73,14 @@ 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", () =>
withTmp((directory) => withHg((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")
@@ -96,16 +102,47 @@ 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", () =>
withTmp((directory) => withHg((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"))
@@ -117,15 +154,14 @@ 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", () =>
withTmp((directory) => withHg((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")
@@ -139,15 +175,14 @@ 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", () =>
withTmp((directory) => withHg((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")
}) })
@@ -164,7 +199,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)), }),
), ),
) )
}) })
+54 -17
View File
@@ -2,18 +2,21 @@ 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, Layer } from "effect" import { Effect, Fiber, Layer, Stream } 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(Vcs.node, [ LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [
[ [
Location.node, Location.node,
Layer.succeed( Layer.succeed(
@@ -35,6 +38,13 @@ 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()
@@ -53,6 +63,7 @@ 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([])
@@ -61,10 +72,9 @@ describe("Vcs", () => {
) )
it.live("reports modified, deleted, and untracked files", () => it.live("reports modified, deleted, and untracked files", () =>
withTmp((directory) => withGit((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")
@@ -79,15 +89,45 @@ 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", () =>
withTmp((directory) => withGit((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")
@@ -105,16 +145,15 @@ 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", () =>
withTmp((directory) => withGit((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"))
@@ -126,15 +165,14 @@ 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", () =>
withTmp((directory) => withGit((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
@@ -142,15 +180,14 @@ 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", () =>
withTmp((directory) => withGit((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")
}) })
@@ -167,7 +204,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 })), }),
), ),
) )
}) })
+11 -4
View File
@@ -1,10 +1,9 @@
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 { JsonSchema } from "effect" import type { Effect, JsonSchema } from "effect"
import type { Hooks } from "./registration.js" import type { Hooks } from "./registration.js"
export interface SessionContext { export interface SessionContext {
@@ -16,15 +15,23 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }> tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
} }
export interface SessionRequest extends HttpRequest { export interface SessionHttp {
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 request: SessionRequest readonly http: SessionHttp
} }
export type SessionDomain = Pick< export type SessionDomain = Pick<
+10 -3
View File
@@ -1,6 +1,5 @@
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"
@@ -16,15 +15,23 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }> tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
} }
export interface SessionRequest extends HttpRequest { export interface SessionHttp {
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 request: SessionRequest readonly http: SessionHttp
} }
export type SessionDomain = Pick< export type SessionDomain = Pick<
+6
View File
@@ -19,6 +19,7 @@ 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"
@@ -113,6 +114,11 @@ 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>
+153 -6
View File
@@ -851,6 +851,16 @@
} }
] ]
}, },
"title": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"agent": { "agent": {
"anyOf": [ "anyOf": [
{ {
@@ -11301,6 +11311,104 @@
} }
} }
}, },
"/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": [
@@ -12032,11 +12140,15 @@
}, },
"directory": { "directory": {
"type": "string" "type": "string"
},
"canonical": {
"type": "string"
} }
}, },
"required": [ "required": [
"id", "id",
"directory" "directory",
"canonical"
], ],
"additionalProperties": false "additionalProperties": false
} }
@@ -12498,7 +12610,6 @@
"cost", "cost",
"tokens", "tokens",
"time", "time",
"title",
"location" "location"
], ],
"additionalProperties": false "additionalProperties": false
@@ -13837,6 +13948,15 @@
}, },
"message": { "message": {
"type": "string" "type": "string"
},
"status": {
"type": "integer",
"allOf": [
{
"minimum": 100,
"maximum": 599
}
]
} }
}, },
"required": [ "required": [
@@ -20765,7 +20885,7 @@
"id": { "id": {
"type": "string" "type": "string"
}, },
"worktree": { "canonical": {
"type": "string" "type": "string"
}, },
"vcs": { "vcs": {
@@ -20792,7 +20912,7 @@
}, },
"required": [ "required": [
"id", "id",
"worktree", "canonical",
"time", "time",
"sandboxes" "sandboxes"
], ],
@@ -20806,11 +20926,15 @@
}, },
"directory": { "directory": {
"type": "string" "type": "string"
},
"canonical": {
"type": "string"
} }
}, },
"required": [ "required": [
"id", "id",
"directory" "directory",
"canonical"
], ],
"additionalProperties": false "additionalProperties": false
}, },
@@ -22445,7 +22569,6 @@
"slug", "slug",
"projectID", "projectID",
"directory", "directory",
"title",
"version", "version",
"time" "time"
], ],
@@ -29127,6 +29250,30 @@
], ],
"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,6 +13,20 @@ 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,6 +46,9 @@ 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,
})), })),
+12 -1
View File
@@ -1,7 +1,18 @@
export * as Vcs from "./vcs.js" export * as Vcs from "./vcs.js"
import { Schema } from "effect" import { Schema } from "effect"
import { NonNegativeInt } from "./schema.js" import { NonNegativeInt, optional } from "./schema.js"
export const Branch = Schema.Struct({
current: optional(Schema.String),
default: optional(Schema.String),
}).annotate({ identifier: "Vcs.Branch" })
export interface Branch extends Schema.Schema.Type<typeof Branch> {}
export const Info = Schema.Struct({
branch: Branch,
}).annotate({ identifier: "Vcs.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Mode = Schema.Literals(["working", "branch"]).annotate({ identifier: "Vcs.Mode" }) export const Mode = Schema.Literals(["working", "branch"]).annotate({ identifier: "Vcs.Mode" })
export type Mode = typeof Mode.Type export type Mode = typeof Mode.Type
@@ -16,6 +16,7 @@ import { FileDiff } from "../src/file-diff.js"
import { Money } from "../src/money.js" import { Money } from "../src/money.js"
import { Skill } from "../src/skill.js" import { Skill } from "../src/skill.js"
import { Shell } from "../src/shell.js" import { Shell } from "../src/shell.js"
import { Vcs } from "../src/vcs.js"
import { PersistedRevert } from "../src/session-revert.js" import { PersistedRevert } from "../src/session-revert.js"
import { AbsolutePath, optional } from "../src/schema.js" import { AbsolutePath, optional } from "../src/schema.js"
@@ -134,6 +135,10 @@ describe("contract hygiene", () => {
expect(Pty.ID.create()).toStartWith("pty_") expect(Pty.ID.create()).toStartWith("pty_")
}) })
test("VCS info omits unavailable branch names", () => {
expect(Schema.encodeSync(Vcs.Info)({ branch: { current: undefined, default: undefined } })).toEqual({ branch: {} })
})
test("reusable public identifiers are stable and unique", () => { test("reusable public identifiers are stable and unique", () => {
const identifiers = [ const identifiers = [
Agent.Color, Agent.Color,
@@ -166,6 +171,8 @@ describe("contract hygiene", () => {
SessionPending.SyntheticData, SessionPending.SyntheticData,
SessionPending.User, SessionPending.User,
SessionPending.Synthetic, SessionPending.Synthetic,
Vcs.Branch,
Vcs.Info,
].map((schema) => schema.ast.annotations?.identifier) ].map((schema) => schema.ast.annotations?.identifier)
expect(identifiers.every((identifier) => typeof identifier === "string")).toBe(true) expect(identifiers.every((identifier) => typeof identifier === "string")).toBe(true)
+8
View File
@@ -7,6 +7,14 @@ import { response } from "../location"
export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) => export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) =>
Effect.gen(function* () { Effect.gen(function* () {
return handlers return handlers
.handle("vcs.get", () =>
response(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.info()
}),
),
)
.handle("vcs.status", () => .handle("vcs.status", () =>
response( response(
Effect.gen(function* () { Effect.gen(function* () {
+10 -8
View File
@@ -327,10 +327,7 @@ export function Prompt(props: PromptProps) {
if (!session) return if (!session) return
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent) const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
if (agent && !args.agent) local.agent.set(agent.id) if (agent && !args.agent) local.agent.set(agent.id)
if (session.model) { if (!local.model.hydrate(session.model)) return
local.model.set({ providerID: session.model.providerID, modelID: session.model.id })
local.model.variant.set(session.model.variant)
}
syncedSessionID = sessionID syncedSessionID = sessionID
}) })
@@ -1329,12 +1326,17 @@ export function Prompt(props: PromptProps) {
const locationLabel = createMemo(() => { const locationLabel = createMemo(() => {
if (!props.sessionID) { if (!props.sessionID) {
// No session yet: show where the next session will be created. // No session yet: show where the next session will be created.
const directory = currentLocation.ref?.directory ?? data.location.default().directory const location = currentLocation.ref ?? data.location.default()
return abbreviateHome(directory, paths.home) const directory = abbreviateHome(location.directory, paths.home)
const branch = data.location.vcs.info(location)?.branch.current
return branch ? `${directory}:${branch}` : directory
} }
if (status() !== "idle") return if (status() !== "idle") return
const directory = data.session.get(props.sessionID)?.location.directory const location = data.session.get(props.sessionID)?.location
return directory ? abbreviateHome(directory, paths.home) : undefined if (!location) return
const directory = abbreviateHome(location.directory, paths.home)
const branch = data.location.vcs.info(location)?.branch.current
return branch ? `${directory}:${branch}` : directory
}) })
const spinnerDef = createMemo(() => { const spinnerDef = createMemo(() => {
+5 -34
View File
@@ -15,7 +15,7 @@ import {
type SessionTab, type SessionTab,
type SessionTabUnread, type SessionTabUnread,
} from "../context/session-tabs-model" } from "../context/session-tabs-model"
import { createAnimatable, spring, tween } from "../ui/animation" import { createAnimatable, spring } from "../ui/animation"
import { Locale } from "../util/locale" import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width" import { stringWidth } from "../util/string-width"
import { TabPulse, unreadGlowIntensity } from "./tab-pulse" import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
@@ -554,21 +554,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const glowColor = () => feedbackColor() ?? accent() const glowColor = () => feedbackColor() ?? accent()
const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined)) const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined))
const title = () => tab.title ?? "Untitled session" const title = () => tab.title ?? "Untitled session"
const [outgoingTitle, setOutgoingTitle] = createSignal<string>()
const wipe = createAnimatable({ front: 1 }, { enabled: animations, transition: tween({ duration: 0.3 }) })
createEffect((previous: string) => {
const next = title()
if (next === previous) return next
if (previous === NEW_SESSION_TAB_TITLE) {
setOutgoingTitle(undefined)
wipe.jump({ front: 1 })
return next
}
setOutgoingTitle(previous)
wipe.jump({ front: 0 })
wipe.animate({ front: 1 })
return next
}, title())
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1) const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
// The number cell keeps one trailing space, even for double-digit tabs. // The number cell keeps one trailing space, even for double-digit tabs.
const numberWidth = () => String(tabNumber()).length + 1 const numberWidth = () => String(tabNumber()).length + 1
@@ -577,20 +562,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
Math.max(1, width() - 1 - numberWidth() - (hovered() === tab.sessionID ? 2 : 0)) Math.max(1, width() - 1 - numberWidth() - (hovered() === tab.sessionID ? 2 : 0))
const visibleTitle = createMemo(() => Locale.takeWidth(title(), availableTitleWidth())) const visibleTitle = createMemo(() => Locale.takeWidth(title(), availableTitleWidth()))
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle())) const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const outgoingTitleParts = createMemo(() => {
const outgoing = outgoingTitle()
if (outgoing === undefined) return undefined
return Locale.graphemes(Locale.takeWidth(outgoing, availableTitleWidth()))
})
// A new title wipes in from the left over the previous one.
const displayedParts = createMemo(() => {
const front = wipe.value().front
const parts = visibleTitleParts()
const previous = outgoingTitleParts()
if (previous === undefined || front >= 1) return parts
const cut = Math.round(front * Math.max(parts.length, previous.length))
return [...parts.slice(0, cut), ...previous.slice(cut)]
})
const titleFades = createMemo( const titleFades = createMemo(
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH, () => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH,
) )
@@ -603,8 +574,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const characterColor = (index: number) => { const characterColor = (index: number) => {
const base = foreground() const base = foreground()
const color = glows() ? glowTextColor(base, glowColor(), 1 + numberWidth() + index, width()) : base const color = glows() ? glowTextColor(base, glowColor(), 1 + numberWidth() + index, width()) : base
if (!titleFades() || index < displayedParts().length - FADE_WIDTH) return color if (!titleFades() || index < visibleTitleParts().length - FADE_WIDTH) return color
const position = index - (displayedParts().length - FADE_WIDTH) const position = index - (visibleTitleParts().length - FADE_WIDTH)
return tint(color, background(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1))) return tint(color, background(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
} }
// The running sweep's level under the number cell, reported by the pulse renderable. // The running sweep's level under the number cell, reported by the pulse renderable.
@@ -677,8 +648,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
selectable={false} selectable={false}
attributes={bold()} attributes={bold()}
> >
<Show when={glows() || titleFades()} fallback={displayedParts().join("")}> <Show when={glows() || titleFades()} fallback={visibleTitle()}>
<For each={displayedParts()}> <For each={visibleTitleParts()}>
{(character, index) => <span style={{ fg: characterColor(index()) }}>{character}</span>} {(character, index) => <span style={{ fg: characterColor(index()) }}>{character}</span>}
</For> </For>
</Show> </Show>
+32
View File
@@ -27,6 +27,7 @@ import type {
SessionPendingInfo, SessionPendingInfo,
ShellInfo, ShellInfo,
SkillInfo, SkillInfo,
VcsInfo,
OpenCodeEvent, OpenCodeEvent,
WebSearchProvider, WebSearchProvider,
} from "@opencode-ai/client" } from "@opencode-ai/client"
@@ -49,6 +50,7 @@ type ShellWithLocation = ShellInfo & { readonly location: LocationRef }
type LocationData = { type LocationData = {
info?: LocationGetOutput info?: LocationGetOutput
vcs?: VcsInfo
agent?: AgentInfo[] agent?: AgentInfo[]
command?: CommandInfo[] command?: CommandInfo[]
integration?: IntegrationInfo[] integration?: IntegrationInfo[]
@@ -339,6 +341,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
result.location.skill.invalidate(event.location) result.location.skill.invalidate(event.location)
void result.location.skill.sync(event.location) void result.location.skill.sync(event.location)
break break
case "vcs.branch.updated":
setStore("location", locationKey(event.location ?? defaultLocation()), (data) => ({
...data,
vcs: {
branch: {
...data?.vcs?.branch,
current: event.data.branch,
},
},
}))
break
case "session.agent.selected": case "session.agent.selected":
if (store.session.info[event.data.sessionID]) if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "agent", event.data.agent) setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
@@ -1152,6 +1165,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}) })
const location = ref ?? defaultLocation() const location = ref ?? defaultLocation()
await Promise.all([ await Promise.all([
result.location.vcs.sync(location),
result.location.agent.sync(location), result.location.agent.sync(location),
result.location.command.sync(location), result.location.command.sync(location),
result.location.integration.sync(location), result.location.integration.sync(location),
@@ -1168,6 +1182,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
invalidate(ref?: LocationRef) { invalidate(ref?: LocationRef) {
const location = ref ?? defaultLocation() const location = ref ?? defaultLocation()
sync.invalidate(`location:${locationKey(location)}`) sync.invalidate(`location:${locationKey(location)}`)
result.location.vcs.invalidate(location)
result.location.agent.invalidate(location) result.location.agent.invalidate(location)
result.location.command.invalidate(location) result.location.command.invalidate(location)
result.location.integration.invalidate(location) result.location.integration.invalidate(location)
@@ -1180,6 +1195,22 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
result.shell.invalidate(location) result.shell.invalidate(location)
result.session.form.invalidate("global", location) result.session.form.invalidate("global", location)
}, },
vcs: {
info(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.vcs
},
sync(ref?: LocationRef) {
const location = ref ?? defaultLocation()
return sync.run(`location.vcs:${locationKey(location)}`, async () => {
const response = await client.api.vcs.get({ location: locationQuery(location) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], vcs: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.vcs:${locationKey(ref ?? defaultLocation())}`)
},
},
agent: { agent: {
list(location?: LocationRef) { list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.agent return store.location[locationKey(location ?? defaultLocation())]?.agent
@@ -1377,6 +1408,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setStore("location", key, { ...store.location[key], info: location }) setStore("location", key, { ...store.location[key], info: location })
}) })
.catch((error) => console.error("Failed to preload location", error)) .catch((error) => console.error("Failed to preload location", error))
void result.location.vcs.sync().catch((error) => console.error("Failed to preload VCS info", error))
void result.project.sync().catch((error) => console.error("Failed to preload projects", error)) void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
return return
} }
+45 -18
View File
@@ -6,7 +6,6 @@ import { useEvent } from "./event"
import path from "path" import path from "path"
import { useTuiPaths } from "./runtime" import { useTuiPaths } from "./runtime"
import { useArgs } from "./args" import { useArgs } from "./args"
import { useClient } from "./client"
import { RGBA } from "@opentui/core" import { RGBA } from "@opentui/core"
import { readJson, writeJsonAtomic } from "../util/persistence" import { readJson, writeJsonAtomic } from "../util/persistence"
import { import {
@@ -48,7 +47,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local", name: "Local",
init: () => { init: () => {
const data = useData() const data = useData()
const client = useClient()
const toast = useToast() const toast = useToast()
const theme = useTheme() const theme = useTheme()
const { mode } = useThemes() const { mode } = useThemes()
@@ -210,6 +208,47 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
) )
}) })
function select(model: ModelPreferenceModel, options?: { recent?: boolean }) {
batch(() => {
if (!isModelValid(model)) return
const a = agent.current()
if (!a) return
setModelStore("model", a.id, model)
if (!options?.recent) return
setModelStore("recent", recentModels(model, modelStore.recent))
save()
})
}
function selectVariant(value: string | undefined) {
const model = currentModel()
if (!model) return
const key = modelPreferenceKey(model)
const variant = normalizeModelVariant(value)
if (modelStore.variant[key] === variant) return
setModelStore("variant", key, variant)
save()
}
function matches(model?: { providerID: string; id: string }) {
if (!modelStore.ready) return false
const current = currentModel()
if (!current) return false
if (!model) return true
return current.providerID === model.providerID && current.modelID === model.id
}
function hydrate(model?: { providerID: string; id: string; variant?: string }) {
if (!modelStore.ready) return false
if (!model) return true
if (data.location.model.list() === undefined) return false
const selected = { providerID: model.providerID, modelID: model.id }
if (!isModelValid(selected)) return false
select(selected)
selectVariant(model.variant)
return matches(model)
}
return { return {
current: currentModel, current: currentModel,
get ready() { get ready() {
@@ -221,6 +260,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
favorite() { favorite() {
return modelStore.favorite return modelStore.favorite
}, },
hydrate,
matches,
parsed: createMemo(() => { parsed: createMemo(() => {
const value = currentModel() const value = currentModel()
if (!value) { if (!value) {
@@ -285,18 +326,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setModelStore("recent", recentModels(next, modelStore.recent)) setModelStore("recent", recentModels(next, modelStore.recent))
save() save()
}, },
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) { set: select,
batch(() => {
if (!isModelValid(model)) return
const a = agent.current()
if (!a) return
setModelStore("model", a.id, model)
if (options?.recent) {
setModelStore("recent", recentModels(model, modelStore.recent))
save()
}
})
},
toggleFavorite(model: { providerID: string; modelID: string }) { toggleFavorite(model: { providerID: string; modelID: string }) {
batch(() => { batch(() => {
if (!isModelValid(model)) return if (!isModelValid(model)) return
@@ -333,10 +363,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return info?.variants?.map((variant) => variant.id) ?? [] return info?.variants?.map((variant) => variant.id) ?? []
}, },
set(value: string | undefined) { set(value: string | undefined) {
const m = currentModel() selectVariant(value)
if (!m) return
setModelStore("variant", modelPreferenceKey(m), normalizeModelVariant(value))
save()
}, },
cycle() { cycle() {
const variants = this.list() const variants = this.list()
@@ -3,9 +3,12 @@ import { createMemo, Show } from "solid-js"
import { FilePath } from "../../ui/file-path" import { FilePath } from "../../ui/file-path"
function View(props: { context: Plugin.Context }) { function View(props: { context: Plugin.Context }) {
const directory = createMemo(() => const directory = createMemo(() => {
props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined, if (!props.context.location) return undefined
) const value = props.context.ui.format.path(props.context.location.directory)
const branch = props.context.data.location.vcs.info(props.context.location)?.branch.current
return branch ? `${value}:${branch}` : value
})
return ( return (
<Show when={directory()}> <Show when={directory()}>
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.text.subdued} />} {(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.text.subdued} />}
-20
View File
@@ -125,7 +125,6 @@ type ToolName =
| "webfetch" | "webfetch"
| "websearch" | "websearch"
| "skill" | "skill"
| "plan_exit"
type ToolRule = { type ToolRule = {
view: ToolView view: ToolView
@@ -516,15 +515,6 @@ function runLsp(p: ToolProps): ToolInline {
} }
} }
function runPlanExit(p: ToolProps): ToolInline {
return {
icon: "→",
title: "Switching to build agent",
mode: "block",
body: p.frame.status === "completed" ? p.frame.output : undefined,
}
}
function patchTitle(file: PatchFile, directory?: string): string { function patchTitle(file: PatchFile, directory?: string): string {
if (file.status === "added") { if (file.status === "added") {
return `# Created ${toolPath(file.file, { directory })}` return `# Created ${toolPath(file.file, { directory })}`
@@ -1077,16 +1067,6 @@ const TOOL_RULES = {
start: scrollSkillStart, start: scrollSkillStart,
}, },
}, },
plan_exit: {
view: {
output: true,
final: false,
},
run: runPlanExit,
scroll: {
start: () => "",
},
},
} as const satisfies ToolRegistry } as const satisfies ToolRegistry
function key(name: string): name is ToolName { function key(name: string): name is ToolName {
+1 -1
View File
@@ -360,7 +360,7 @@ export function Session() {
createEffect(() => { createEffect(() => {
const current = prompt() const current = prompt()
if (sent || !current || !synced() || !local.model.ready) return if (sent || !current || !synced() || !local.model.ready) return
if (!local.agent.current() || !local.model.current()) return if (!local.agent.current() || !local.model.matches(session()?.model)) return
if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return
sent = true sent = true
current.submit() current.submit()
+38 -7
View File
@@ -226,30 +226,42 @@ test("session title generated while an untitled session is loading remains visib
} }
}) })
test("session startup prompt is submitted exactly once", async () => { for (const scenario of [
{ name: "session startup prompt is submitted exactly once", delayed: false },
{ name: "session model hydration retries after the model catalog loads", delayed: true },
]) {
test(scenario.name, async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core") const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const events = createEventStream() const events = createEventStream()
const cwd = process.cwd() const cwd = process.cwd()
const location = { directory: cwd, project: { id: "project", directory: cwd } } const location = { directory: cwd, project: { id: "project", directory: cwd } }
const selected = scenario.delayed ? "selected" : "model"
const session = { const session = {
id: "dummy", id: "dummy",
title: "Demo session", title: "Demo session",
projectID: "project", projectID: "project",
location: { directory: cwd }, location: { directory: cwd },
agent: "build", agent: "build",
model: { providerID: "provider", id: "model" }, model: { providerID: "provider", id: selected },
cost: 0, cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 }, time: { created: 0, updated: 0 },
} }
const bodies: unknown[] = [] const sessionRequested = Promise.withResolvers<void>()
const modelRequested = Promise.withResolvers<void>()
const releaseModels = Promise.withResolvers<void>()
const promptSubmitted = Promise.withResolvers<void>() const promptSubmitted = Promise.withResolvers<void>()
const bodies: unknown[] = []
const selections: unknown[] = []
const calls = createFetch(async (url, request) => { const calls = createFetch(async (url, request) => {
if (url.pathname === "/api/location") return json(location) if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === "/api/session/dummy") return json({ data: session }) if (url.pathname === "/api/session/dummy") {
sessionRequested.resolve()
return json({ data: session })
}
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} }) if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/dummy/pending") return json({ data: [] }) if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] }) if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
@@ -258,11 +270,21 @@ test("session startup prompt is submitted exactly once", async () => {
location, location,
data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }], data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }],
}) })
if (url.pathname === "/api/model") if (url.pathname === "/api/model") {
modelRequested.resolve()
if (scenario.delayed) await releaseModels.promise
return json({ return json({
location, location,
data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }], data: [
...(scenario.delayed ? [{ id: "fallback", providerID: "provider", name: "Fallback", variants: [] }] : []),
{ id: selected, providerID: "provider", name: "Selected", variants: [] },
],
}) })
}
if (url.pathname === "/api/session/dummy/model") {
selections.push(await request.json())
return new Response(null, { status: 204 })
}
if (url.pathname === "/api/session/dummy/prompt") { if (url.pathname === "/api/session/dummy/prompt") {
bodies.push(await request.json()) bodies.push(await request.json())
promptSubmitted.resolve() promptSubmitted.resolve()
@@ -284,6 +306,12 @@ test("session startup prompt is submitted exactly once", async () => {
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
) )
if (scenario.delayed) {
await Promise.all([sessionRequested.promise, modelRequested.promise])
await Bun.sleep(20)
expect(bodies).toEqual([])
releaseModels.resolve()
}
await Promise.race([ await Promise.race([
promptSubmitted.promise, promptSubmitted.promise,
Bun.sleep(2000).then(() => { Bun.sleep(2000).then(() => {
@@ -296,9 +324,12 @@ test("session startup prompt is submitted exactly once", async () => {
expect(bodies).toHaveLength(1) expect(bodies).toHaveLength(1)
expect(bodies[0]).toMatchObject({ text: "RESUME_READY" }) expect(bodies[0]).toMatchObject({ text: "RESUME_READY" })
expect(selections).toEqual([])
} finally { } finally {
releaseModels.resolve()
if (!setup.renderer.isDestroyed) setup.renderer.destroy() if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop() await server.stop()
mock.restore() mock.restore()
} }
}) })
}
+43
View File
@@ -106,6 +106,49 @@ test("does not preload session summaries into the data context", async () => {
} }
}) })
test("syncs VCS info and applies branch updates", async () => {
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname !== "/api/vcs") return undefined
return json({
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
data: { branch: { current: "main", default: "main" } },
})
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
await wait(() => data.location.vcs.info()?.branch.current === "main")
emitEvent(events, {
id: "evt_vcs_branch",
created: Date.now(),
type: "vcs.branch.updated",
data: { branch: "feature" },
})
await wait(() => data.location.vcs.info()?.branch.current === "feature")
expect(data.location.vcs.info()?.branch).toEqual({ current: "feature", default: "main" })
} finally {
app.renderer.destroy()
}
})
test("proactively syncs project metadata newest first", async () => { test("proactively syncs project metadata newest first", async () => {
const events = createEventStream() const events = createEventStream()
const calls = createFetch((url) => { const calls = createFetch((url) => {
+5
View File
@@ -95,6 +95,11 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory }) if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
if (url.pathname === "/api/location") if (url.pathname === "/api/location")
return json({ directory, project: { id: "proj_test", directory: worktree, canonical: worktree } }) return json({ directory, project: { id: "proj_test", directory: worktree, canonical: worktree } })
if (url.pathname === "/api/vcs")
return json({
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
data: { branch: { current: "main", default: "main" } },
})
if (url.pathname === "/api/fs/list") if (url.pathname === "/api/fs/list")
return json({ location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } }, data: [] }) return json({ location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } }, data: [] })
if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree }) if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree })
+3 -2
View File
@@ -13,6 +13,8 @@ const config = path.join(xdgConfig!, app)
const state = path.join(xdgState!, app) const state = path.join(xdgState!, app)
const tmp = path.join(os.tmpdir(), app) const tmp = path.join(os.tmpdir(), app)
await fs.mkdir(tmp, { recursive: true })
const paths = { const paths = {
get home() { get home() {
return process.env.OPENCODE_TEST_HOME ?? os.homedir() return process.env.OPENCODE_TEST_HOME ?? os.homedir()
@@ -24,7 +26,7 @@ const paths = {
cache, cache,
config, config,
state, state,
tmp, tmp: await fs.realpath(tmp),
} }
export const Path = paths export const Path = paths
@@ -35,7 +37,6 @@ await Promise.all([
fs.mkdir(Path.data, { recursive: true }), fs.mkdir(Path.data, { recursive: true }),
fs.mkdir(Path.config, { recursive: true }), fs.mkdir(Path.config, { recursive: true }),
fs.mkdir(Path.state, { recursive: true }), fs.mkdir(Path.state, { recursive: true }),
fs.mkdir(Path.tmp, { recursive: true }),
fs.mkdir(Path.log, { recursive: true }), fs.mkdir(Path.log, { recursive: true }),
fs.mkdir(Path.bin, { recursive: true }), fs.mkdir(Path.bin, { recursive: true }),
fs.mkdir(Path.repos, { recursive: true }), fs.mkdir(Path.repos, { recursive: true }),
-1
View File
@@ -20,7 +20,6 @@
"@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",
@@ -81,8 +81,7 @@ current built-in actions use these resources:
| `<server>_<tool>` | `*` for an MCP tool; unsupported characters in both names become `_` | | `<server>_<tool>` | `*` for an MCP tool; unsupported characters in both names become `_` |
| `execute` | `*`; controls availability of the Code Mode dispatcher, while each nested tool still enforces its own permission | | `execute` | `*`; controls availability of the Code Mode dispatcher, while each nested tool still enforces its own permission |
Built-in agent policy also reserves `plan_enter` and `plan_exit` for plan-mode `doom_loop` and `lsp` are not current V2 Core permission actions.
transitions. `doom_loop` and `lsp` are not current V2 Core permission actions.
## External directories ## External directories
@@ -132,7 +131,9 @@ matching, so authorize only trusted directory boundaries.
## Defaults ## Defaults
The evaluator's fallback is `ask`, but shipped agents include ordered defaults: Every agent, including custom agents, starts with ordered defaults that allow
tools, ask for external directories, ask for `.env` reads, and allow
`.env.example` reads. Shipped agents then add their own policies:
| Agent | Effective default policy | | Agent | Effective default policy |
| --- | --- | | --- | --- |
@@ -153,8 +154,12 @@ The base read rules are ordered as follows:
] ]
``` ```
OpenCode also permits its managed tool-output and temporary directories where OpenCode also permits its managed tool-output, shell-output, temporary, and
needed. These exceptions do not grant general external-directory access. global configuration directories. These exceptions apply only to the
external-directory boundary for every agent; the underlying action still uses
its own permission rules. The environment instructions identify the temporary
directory available for work outside the workspace. Later global and
agent-specific rules can override these defaults.
## Agent overrides ## Agent overrides
+17 -4
View File
@@ -239,17 +239,30 @@ without restarting OpenCode.
### Runtime hooks ### Runtime hooks
Runtime hooks intercept live operations. Their event objects expose specific Runtime hooks intercept live operations:
mutable fields:
| Hook | Mutable fields | | Hook | Mutable fields |
| ------------------------------------------- | ------------------------------------------------------------------------------ | | ------------------------------------------- | ------------------------------------------------------------------------------ |
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` | | `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` | | `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
| `ctx.session.hook("request", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch | | `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
| `ctx.session.hook("http", callback)` | `use`, registering request and response handling |
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes | | `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure | | `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
HTTP hooks can modify requests, inspect responses, retry, or return a
response without calling the provider. It applies to native models; AI SDK
models do not currently pass through this hook.
```ts
await ctx.session.hook("http", (event) => {
event.use((request, next) => {
request.headers.set("x-session-id", event.sessionID)
return next(request)
})
})
```
For example, remove a tool from selected model requests and normalize another For example, remove a tool from selected model requests and normalize another
tool's input: tool's input:
@@ -259,7 +272,7 @@ import { Plugin } from "@opencode-ai/plugin"
export default Plugin.define({ export default Plugin.define({
id: "acme.guards", id: "acme.guards",
setup: async (ctx) => { setup: async (ctx) => {
await ctx.session.hook("request", (event) => { await ctx.session.hook("context", (event) => {
delete event.tools.write delete event.tools.write
}) })
+153 -6
View File
@@ -851,6 +851,16 @@
} }
] ]
}, },
"title": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"agent": { "agent": {
"anyOf": [ "anyOf": [
{ {
@@ -11301,6 +11311,104 @@
} }
} }
}, },
"/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": [
@@ -12032,11 +12140,15 @@
}, },
"directory": { "directory": {
"type": "string" "type": "string"
},
"canonical": {
"type": "string"
} }
}, },
"required": [ "required": [
"id", "id",
"directory" "directory",
"canonical"
], ],
"additionalProperties": false "additionalProperties": false
} }
@@ -12498,7 +12610,6 @@
"cost", "cost",
"tokens", "tokens",
"time", "time",
"title",
"location" "location"
], ],
"additionalProperties": false "additionalProperties": false
@@ -13837,6 +13948,15 @@
}, },
"message": { "message": {
"type": "string" "type": "string"
},
"status": {
"type": "integer",
"allOf": [
{
"minimum": 100,
"maximum": 599
}
]
} }
}, },
"required": [ "required": [
@@ -20765,7 +20885,7 @@
"id": { "id": {
"type": "string" "type": "string"
}, },
"worktree": { "canonical": {
"type": "string" "type": "string"
}, },
"vcs": { "vcs": {
@@ -20792,7 +20912,7 @@
}, },
"required": [ "required": [
"id", "id",
"worktree", "canonical",
"time", "time",
"sandboxes" "sandboxes"
], ],
@@ -20806,11 +20926,15 @@
}, },
"directory": { "directory": {
"type": "string" "type": "string"
},
"canonical": {
"type": "string"
} }
}, },
"required": [ "required": [
"id", "id",
"directory" "directory",
"canonical"
], ],
"additionalProperties": false "additionalProperties": false
}, },
@@ -22445,7 +22569,6 @@
"slug", "slug",
"projectID", "projectID",
"directory", "directory",
"title",
"version", "version",
"time" "time"
], ],
@@ -29127,6 +29250,30 @@
], ],
"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": {
+153 -6
View File
@@ -851,6 +851,16 @@
} }
] ]
}, },
"title": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"agent": { "agent": {
"anyOf": [ "anyOf": [
{ {
@@ -11301,6 +11311,104 @@
} }
} }
}, },
"/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": [
@@ -12032,11 +12140,15 @@
}, },
"directory": { "directory": {
"type": "string" "type": "string"
},
"canonical": {
"type": "string"
} }
}, },
"required": [ "required": [
"id", "id",
"directory" "directory",
"canonical"
], ],
"additionalProperties": false "additionalProperties": false
} }
@@ -12498,7 +12610,6 @@
"cost", "cost",
"tokens", "tokens",
"time", "time",
"title",
"location" "location"
], ],
"additionalProperties": false "additionalProperties": false
@@ -13837,6 +13948,15 @@
}, },
"message": { "message": {
"type": "string" "type": "string"
},
"status": {
"type": "integer",
"allOf": [
{
"minimum": 100,
"maximum": 599
}
]
} }
}, },
"required": [ "required": [
@@ -20765,7 +20885,7 @@
"id": { "id": {
"type": "string" "type": "string"
}, },
"worktree": { "canonical": {
"type": "string" "type": "string"
}, },
"vcs": { "vcs": {
@@ -20792,7 +20912,7 @@
}, },
"required": [ "required": [
"id", "id",
"worktree", "canonical",
"time", "time",
"sandboxes" "sandboxes"
], ],
@@ -20806,11 +20926,15 @@
}, },
"directory": { "directory": {
"type": "string" "type": "string"
},
"canonical": {
"type": "string"
} }
}, },
"required": [ "required": [
"id", "id",
"directory" "directory",
"canonical"
], ],
"additionalProperties": false "additionalProperties": false
}, },
@@ -22445,7 +22569,6 @@
"slug", "slug",
"projectID", "projectID",
"directory", "directory",
"title",
"version", "version",
"time" "time"
], ],
@@ -29127,6 +29250,30 @@
], ],
"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": {
-174
View File
@@ -1,174 +0,0 @@
diff --git a/dist/index.d.mts b/dist/index.d.mts
index 266c5ffdd9ee74ff95908ce90858ee4369d4e4ae..990ef4195bc67b6d25f249e1c81cf51710390f9a 100644
--- a/dist/index.d.mts
+++ b/dist/index.d.mts
@@ -78,6 +78,7 @@ declare const xaiLanguageModelResponsesOptions: z.ZodObject<{
topLogprobs: z.ZodOptional<z.ZodNumber>;
store: z.ZodOptional<z.ZodBoolean>;
previousResponseId: z.ZodOptional<z.ZodString>;
+ promptCacheKey: z.ZodOptional<z.ZodString>;
include: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodEnum<{
"file_search_call.results": "file_search_call.results";
}>>>>;
diff --git a/dist/index.d.ts b/dist/index.d.ts
index 266c5ffdd9ee74ff95908ce90858ee4369d4e4ae..990ef4195bc67b6d25f249e1c81cf51710390f9a 100644
--- a/dist/index.d.ts
+++ b/dist/index.d.ts
@@ -78,6 +78,7 @@ declare const xaiLanguageModelResponsesOptions: z.ZodObject<{
topLogprobs: z.ZodOptional<z.ZodNumber>;
store: z.ZodOptional<z.ZodBoolean>;
previousResponseId: z.ZodOptional<z.ZodString>;
+ promptCacheKey: z.ZodOptional<z.ZodString>;
include: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodEnum<{
"file_search_call.results": "file_search_call.results";
}>>>>;
diff --git a/dist/index.js b/dist/index.js
index 717b74538f5c8f0d6ab1475ebb2a84a47ccd3950..dd7dbeb3bc307e0d355f4bb4939d06cc8eae7528 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -1119,6 +1119,14 @@ async function convertToXaiResponsesInput({
type: "input_file",
file_url: block.data.toString()
});
+ } else if (block.mediaType === "application/pdf") {
+ contentParts.push({
+ type: "input_file",
+ ...typeof block.data === "string" && block.data.startsWith("file-") ? { file_id: block.data } : {
+ filename: block.filename ?? "file",
+ file_data: `data:application/pdf;base64,${(0, import_provider_utils5.convertToBase64)(block.data)}`
+ }
+ });
} else {
throw new import_provider4.UnsupportedFunctionalityError({
functionality: `file part media type ${block.mediaType} as inline data (xAI Responses requires a URL or a Files API reference for non-image files)`
@@ -1760,6 +1768,10 @@ var xaiLanguageModelResponsesOptions = import_v47.z.object({
* The ID of the previous response from the model.
*/
previousResponseId: import_v47.z.string().optional(),
+ /**
+ * A stable key used by xAI to improve prompt cache routing.
+ */
+ promptCacheKey: import_v47.z.string().optional(),
/**
* Specify additional output data to include in the model response.
* Example values: 'file_search_call.results'.
@@ -2165,6 +2177,9 @@ var XaiResponsesLanguageModel = class {
},
...options.previousResponseId != null && {
previous_response_id: options.previousResponseId
+ },
+ ...options.promptCacheKey != null && {
+ prompt_cache_key: options.promptCacheKey
}
};
if (xaiTools2 && xaiTools2.length > 0) {
diff --git a/dist/index.mjs b/dist/index.mjs
index a26af109585fc2bd3053b320142aa869c06d36f4..774adaf971b648544317a4fc65d0c56e488d4fc7 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -1122,6 +1122,14 @@ async function convertToXaiResponsesInput({
type: "input_file",
file_url: block.data.toString()
});
+ } else if (block.mediaType === "application/pdf") {
+ contentParts.push({
+ type: "input_file",
+ ...typeof block.data === "string" && block.data.startsWith("file-") ? { file_id: block.data } : {
+ filename: block.filename ?? "file",
+ file_data: `data:application/pdf;base64,${convertToBase642(block.data)}`
+ }
+ });
} else {
throw new UnsupportedFunctionalityError3({
functionality: `file part media type ${block.mediaType} as inline data (xAI Responses requires a URL or a Files API reference for non-image files)`
@@ -1763,6 +1771,10 @@ var xaiLanguageModelResponsesOptions = z7.object({
* The ID of the previous response from the model.
*/
previousResponseId: z7.string().optional(),
+ /**
+ * A stable key used by xAI to improve prompt cache routing.
+ */
+ promptCacheKey: z7.string().optional(),
/**
* Specify additional output data to include in the model response.
* Example values: 'file_search_call.results'.
@@ -2186,6 +2198,9 @@ var XaiResponsesLanguageModel = class {
},
...options.previousResponseId != null && {
previous_response_id: options.previousResponseId
+ },
+ ...options.promptCacheKey != null && {
+ prompt_cache_key: options.promptCacheKey
}
};
if (xaiTools2 && xaiTools2.length > 0) {
diff --git a/src/responses/convert-to-xai-responses-input.ts b/src/responses/convert-to-xai-responses-input.ts
index 9a3712990cf646a6f7ba757d3f2893a3c37c6ef9..b018a9b95eb21e6c12794e6bddb3d953f40441e5 100644
--- a/src/responses/convert-to-xai-responses-input.ts
+++ b/src/responses/convert-to-xai-responses-input.ts
@@ -64,6 +64,17 @@ export async function convertToXaiResponsesInput({
type: 'input_file',
file_url: block.data.toString(),
});
+ } else if (block.mediaType === 'application/pdf') {
+ contentParts.push({
+ type: 'input_file',
+ ...(typeof block.data === 'string' &&
+ block.data.startsWith('file-')
+ ? { file_id: block.data }
+ : {
+ filename: block.filename ?? 'file',
+ file_data: `data:application/pdf;base64,${convertToBase64(block.data)}`,
+ }),
+ });
} else {
throw new UnsupportedFunctionalityError({
functionality: `file part media type ${block.mediaType} as inline data (xAI Responses requires a URL or a Files API reference for non-image files)`,
diff --git a/src/responses/xai-responses-api.ts b/src/responses/xai-responses-api.ts
index 70483ea4f7c340b84c23766ac1d1df21e052842b..4b4d052860caa019556151e9e46dbfda8dfb18a9 100644
--- a/src/responses/xai-responses-api.ts
+++ b/src/responses/xai-responses-api.ts
@@ -27,7 +27,13 @@ export type XaiResponsesSystemMessage = {
export type XaiResponsesUserMessageContentPart =
| { type: 'input_text'; text: string }
| { type: 'input_image'; image_url: string }
- | { type: 'input_file'; file_url: string };
+ | {
+ type: 'input_file';
+ file_url?: string;
+ file_id?: string;
+ file_data?: string;
+ filename?: string;
+ };
export type XaiResponsesUserMessage = {
role: 'user';
diff --git a/src/responses/xai-responses-language-model.ts b/src/responses/xai-responses-language-model.ts
index f90df62eb9a30154388b1390e9f3acc3ccc022bf..00e61cba6cf048ae0045be692f33cb7e1e6a1188 100644
--- a/src/responses/xai-responses-language-model.ts
+++ b/src/responses/xai-responses-language-model.ts
@@ -182,6 +182,9 @@ export class XaiResponsesLanguageModel implements LanguageModelV3 {
...(options.previousResponseId != null && {
previous_response_id: options.previousResponseId,
}),
+ ...(options.promptCacheKey != null && {
+ prompt_cache_key: options.promptCacheKey,
+ }),
};
if (xaiTools && xaiTools.length > 0) {
diff --git a/src/responses/xai-responses-options.ts b/src/responses/xai-responses-options.ts
index f8e96c061bf8793a402ababb8cad65bb2ad6aead..15c168892c1e8755453c61d3061e958cfd51ac71 100644
--- a/src/responses/xai-responses-options.ts
+++ b/src/responses/xai-responses-options.ts
@@ -32,6 +32,10 @@ export const xaiLanguageModelResponsesOptions = z.object({
* The ID of the previous response from the model.
*/
previousResponseId: z.string().optional(),
+ /**
+ * A stable key used by xAI to improve prompt cache routing.
+ */
+ promptCacheKey: z.string().optional(),
/**
* Specify additional output data to include in the model response.
* Example values: 'file_search_call.results'.