Compare commits

..
1 Commits
Author SHA1 Message Date
opencode 557734bd13 release: v1.14.31 2026-05-01 06:13:37 +00:00
141 changed files with 1135 additions and 1902 deletions
-1
View File
@@ -32,7 +32,6 @@ rekram1-node
-ricardo-m-l
-robinmordasiewicz
rubdos
-saisharan0103 spamming ai prs
shantur
simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team
+48 -40
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.32",
"version": "1.14.31",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.32",
"version": "1.14.31",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -119,7 +119,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.14.32",
"version": "1.14.31",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -146,7 +146,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.14.32",
"version": "1.14.31",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -170,7 +170,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.14.32",
"version": "1.14.31",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -194,7 +194,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.14.32",
"version": "1.14.31",
"bin": {
"opencode": "./bin/opencode",
},
@@ -228,7 +228,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.32",
"version": "1.14.31",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -263,7 +263,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.14.32",
"version": "1.14.31",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -309,7 +309,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.32",
"version": "1.14.31",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -338,7 +338,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.32",
"version": "1.14.31",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -354,7 +354,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.32",
"version": "1.14.31",
"bin": {
"opencode": "./bin/opencode",
},
@@ -496,7 +496,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.32",
"version": "1.14.31",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -511,8 +511,8 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.2.2",
"@opentui/solid": ">=0.2.2",
"@opentui/core": ">=0.2.0",
"@opentui/solid": ">=0.2.0",
},
"optionalPeers": [
"@opentui/core",
@@ -531,7 +531,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.32",
"version": "1.14.31",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -546,7 +546,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.32",
"version": "1.14.31",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -581,7 +581,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.32",
"version": "1.14.31",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -630,7 +630,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.32",
"version": "1.14.31",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -690,8 +690,8 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.2",
"@opentui/solid": "0.2.2",
"@opentui/core": "0.2.0",
"@opentui/solid": "0.2.0",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -1618,21 +1618,21 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.2", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.2", "@opentui/core-darwin-x64": "0.2.2", "@opentui/core-linux-arm64": "0.2.2", "@opentui/core-linux-x64": "0.2.2", "@opentui/core-win32-arm64": "0.2.2", "@opentui/core-win32-x64": "0.2.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wxg1CD58SVrowu+WgbhZNi3UP/wWxPio2Kj2IeTjomoIE+6EXLxR8eCCxHYVuQUd9E4fknrKkY5HmiSsp6oPow=="],
"@opentui/core": ["@opentui/core@0.2.0", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.2.0", "@opentui/core-darwin-x64": "0.2.0", "@opentui/core-linux-arm64": "0.2.0", "@opentui/core-linux-x64": "0.2.0", "@opentui/core-win32-arm64": "0.2.0", "@opentui/core-win32-x64": "0.2.0", "bun-webgpu": "0.1.7", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-7YOEqPUQmsgrOb9nmLEBlX8RVHPFy4HquK1C489DwfvvPTiws8nTbZ+webNQDWha7shgnYQK4Zo1EcOlpQ5+1Q=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tY5n3ZRQx+b0kyhQJJLsyJMeZ+0w4FV37YZc/Qqv3qvOqE9kZPw/7adR77FYwWDm/7fax94mLMrR8Y5bKUkDmw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VVmKwth3hzsQPjAZ7WGJxmzuzx0uCtynd79JJDg26D7QRM9V5beVGbKwwU5SKsDlK74EyQoY85Mv9xFY5E4jrA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-W/R7OnqY30FXcTG0tiP2JkQFmgtYbIte5afQ5PC12TliRoee1RqG3iCG6kY1jxW+3Vg6jge88uiSjUEDpeV2gA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-eX+WNdbSNr7Bozdq/MH6p1vXIALGt0SqBHR4YtWyTh6X7KDz9FTtJT3ylxMPqiVRUGBNAiWOxoqKGXW7JLQ0TA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1pzTYFEZauYuw6AGycw2TYGtAlZVGjuUtSdxH1fP51kBPS3oVWduUY2j7GKREz3SU5NulvO2Wc6HWsm3feMqwQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ARZa+ywbN/OV7esT5ZdJMlQW3a4Pr56qLlEI/X65ik88C2sgmDze4Kf2FmqtvJ1hbv1YsMfLHH9MfhLl5twyHQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ucVwUtUYeOYGVFPBLbPoxzbrPdhD0PDyKNQ2X4n1AJ9jlQX4gqBZRcXMEF8hiXDjFxsikZwef7De0ciCcWvAMg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZjNxrD45P51cdbABoivVQLBakVYwDqAridJbHhkK6T/+EU7YsTrmAu9ae19N9ZGnrlKzLViQF8GOavNUNjAbhw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-MPhYdJNdxmC5Bqsq6sis/+VkjRgkEjm+bQ1Tl++NSKLuiTU32Re0ImcZlgHbe+LZtZoGMZHVSgZlkGd3oYXO2g=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ImMjFPOWE8wcZQ2lUz1D418xonS/5EwnItUF1g5dbp1q9+A0vv2P3bxTenLwMqcYvG4wjO6gKT3n2QLnRd6qKg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-19BroLfn2h0RDYfJS5o96Fc8kYCDhRBcseIXtHIkoKIsKMxx62KiDLo/byVye6rp+yQRRB7Xkd2uWqsbdiWo9w=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6yfYHTtJ4yzbl8kXCW3Pc4eWbZDYVw21GumwdNgkjJJ2JqQAQ861em0riEoucYAa5qPYYTiMUEw7X4Fv8lGwuQ=="],
"@opentui/solid": ["@opentui/solid@0.2.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-ZBVfCoVAhcUGQWPAWOTdzuVldMaRkuPpCu4U1VZCqmIw9DtbCuiVr0WnDocDxKhJLbTu8bl3qEWtVCf6lTSi3w=="],
"@opentui/solid": ["@opentui/solid@0.2.0", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.0", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-kZR9i0FPAcVtomrPsKuSb+D9smooplo9zggFfU2vnnguNuQjGNbEmuJtxhCacy7ig9g3GomdNtQAzD4LiAY+3w=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2768,21 +2768,21 @@
"builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
"bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
"bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
"bun-webgpu": ["bun-webgpu@0.1.7", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.7", "bun-webgpu-darwin-x64": "^0.1.7", "bun-webgpu-linux-x64": "^0.1.7", "bun-webgpu-win32-x64": "^0.1.7" } }, "sha512-KUxUp+oQIf7pPBMD4Hv1TUu7DWaOZ4ciKulTk9to9+Uc8yHoYrMW7L2SJCJ4FHHkywgf/7aLRgRx0b7i6DvGIQ=="],
"bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lIsDkPzJzPl6yrB5CUOINJFPnTRv6fF/Q8J1mAr43ogSp86WZEg9XZKaT6f3EUJ+9ETogGoMnoj1q0AwHUTbAQ=="],
"bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mRrFFyHzPWjsTRidAZBRcu808CPQBOUL0P6b4nxLhp+XHcV/mbUHERZMgW9s58tsojQfSdzschiQa8q+JCgRWA=="],
"bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-uEddf5U7GvKIkM/BV18rUKtYHL6d0KeqBjNHwfqDH9QgEo9KVSKvJXS5I/sMefk5V5pIYE+8tQhtrREevhocng=="],
"bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-g0NXGNgvaVCSH/jCWWlfdiquOHkbUN6vP4zqzSkIxWKQeLnqm3oADcok7SO3yIgI7v5mKpRc/ks7NDEKNH+jNQ=="],
"bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Y/f15j9r8ba0xUz+3lATtS74OE+PPzQXO7Do/1eCluJcuOlfa77kMjvBK/ShWnem3Y9xqi59pebTPOGRB+CaJA=="],
"bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.7", "", { "os": "linux", "cpu": "x64" }, "sha512-UEP7UZdEhx9otvkZczjsszL8ZVlrODANQvgl+C88/bNVmxDoFi7w1fWzGi1sZyakiETjmtFDq2/xCLhbSZxjqw=="],
"bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-MHSFAKqizISb+C5NfDrFe3g0Al5Njnu0j/A+oO2Q+bIWX+fUYjBSowiYE1ZXJx65KuryuB+tiM7Qh6cQbVvkEg=="],
"bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.7", "", { "os": "win32", "cpu": "x64" }, "sha512-KZktiFkBz6sN7PEm1NVdeaLP5Q5X/PlSHZqefY4nNuWtf0LNvh54NhZe7yVv/Plz/nGbv92b0KHMBY3ki/pp6g=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -4204,7 +4204,7 @@
"pagefind": ["pagefind@1.5.2", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.5.2", "@pagefind/darwin-x64": "1.5.2", "@pagefind/freebsd-x64": "1.5.2", "@pagefind/linux-arm64": "1.5.2", "@pagefind/linux-x64": "1.5.2", "@pagefind/windows-arm64": "1.5.2", "@pagefind/windows-x64": "1.5.2" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q=="],
"pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="],
@@ -5640,8 +5640,6 @@
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
"@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
@@ -6124,6 +6122,8 @@
"type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
@@ -6132,8 +6132,6 @@
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"venice-ai-sdk-provider/@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=="],
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
@@ -6800,7 +6798,7 @@
"opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="],
"opentui-spinner/@opentui/core/bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
"opentui-spinner/@opentui/core/bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
"opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
@@ -7160,6 +7158,16 @@
"opencontrol/@modelcontextprotocol/sdk/express/type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"opentui-spinner/@opentui/core/bun-webgpu/@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="],
"opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lIsDkPzJzPl6yrB5CUOINJFPnTRv6fF/Q8J1mAr43ogSp86WZEg9XZKaT6f3EUJ+9ETogGoMnoj1q0AwHUTbAQ=="],
"opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-uEddf5U7GvKIkM/BV18rUKtYHL6d0KeqBjNHwfqDH9QgEo9KVSKvJXS5I/sMefk5V5pIYE+8tQhtrREevhocng=="],
"opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Y/f15j9r8ba0xUz+3lATtS74OE+PPzQXO7Do/1eCluJcuOlfa77kMjvBK/ShWnem3Y9xqi59pebTPOGRB+CaJA=="],
"opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-MHSFAKqizISb+C5NfDrFe3g0Al5Njnu0j/A+oO2Q+bIWX+fUYjBSowiYE1ZXJx65KuryuB+tiM7Qh6cQbVvkEg=="],
"opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"ora/bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
+2 -2
View File
@@ -34,8 +34,8 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.2",
"@opentui/solid": "0.2.2",
"@opentui/core": "0.2.0",
"@opentui/solid": "0.2.0",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.14.32",
"version": "1.14.31",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.32",
"version": "1.14.31",
"type": "module",
"license": "MIT",
"scripts": {
@@ -141,10 +141,7 @@ export async function handler(
)
validateModelSettings(billingSource, authInfo)
updateProviderKey(authInfo, providerInfo)
logger.metric({
provider: providerInfo.id,
"provider.model": providerInfo.model,
})
logger.metric({ provider: providerInfo.id })
const startTimestamp = Date.now()
const reqUrl = providerInfo.modifyUrl(providerInfo.api, isStream)
@@ -152,23 +149,12 @@ export async function handler(
providerInfo.modifyBody({
...createBodyConverter(opts.format, providerInfo.format)(body),
model: providerInfo.model,
...(() => {
const replacer = (obj: Record<string, any>): Record<string, any> =>
Object.fromEntries(
Object.entries(obj).flatMap(([k, v]) => {
if (Array.isArray(v)) return [[k, v]]
if (typeof v === "object") return [[k, replacer(v)]]
if (v === "$ip") return [[k, ip]]
if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : []
if (v.startsWith("$header.")) {
const headerValue = input.request.headers.get(v.slice(8))
return headerValue ? [[k, headerValue]] : []
}
return [[k, v]]
}),
)
return replacer(providerInfo.payloadModifier ?? {})
})(),
...providerInfo.payloadModifier,
...Object.fromEntries(
Object.entries(providerInfo.payloadMappings ?? {})
.map(([k, v]) => [k, input.request.headers.get(v)])
.filter(([_k, v]) => !!v),
),
}),
)
logger.debug("REQUEST URL: " + reqUrl)
@@ -528,6 +514,7 @@ export async function handler(
reqModel,
providerModel: modelProvider.model,
adjustCacheUsage: providerProps.adjustCacheUsage,
safetyIdentifier: modelProvider.safetyIdentifier ? ip : undefined,
workspaceID: authInfo?.workspaceID,
}
if (format === "anthropic") return anthropicHelper(opts)
@@ -23,7 +23,7 @@ type Usage = {
}
}
export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentifier }) => ({
format: "oa-compat",
modifyUrl: (providerApi: string) => providerApi + "/chat/completions",
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
@@ -34,6 +34,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
return {
...body,
...(body.stream ? { stream_options: { include_usage: true } } : {}),
...(safetyIdentifier ? { safety_identifier: safetyIdentifier } : {}),
}
},
createBinaryStreamDecoder: () => undefined,
@@ -18,7 +18,10 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("authorization", `Bearer ${apiKey}`)
},
modifyBody: (body: Record<string, any>) => body,
modifyBody: (body: Record<string, any>) => ({
...body,
...(workspaceID ? { safety_identifier: workspaceID } : {}),
}),
createBinaryStreamDecoder: () => undefined,
streamSeparator: "\n\n",
createUsageParser: () => {
@@ -37,6 +37,7 @@ export type ProviderHelper = (input: {
reqModel: string
providerModel: string
adjustCacheUsage?: boolean
safetyIdentifier?: string
workspaceID?: string
}) => {
format: ZenData.Format
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.14.32",
"version": "1.14.31",
"private": true,
"type": "module",
"license": "MIT",
+1
View File
@@ -40,6 +40,7 @@ export namespace ZenData {
disabled: z.boolean().optional(),
storeModel: z.string().optional(),
payloadModifier: z.record(z.string(), z.any()).optional(),
safetyIdentifier: z.boolean().optional(),
}),
),
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.32",
"version": "1.14.31",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.14.32",
"version": "1.14.31",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.32",
"version": "1.14.31",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+2 -6
View File
@@ -120,17 +120,13 @@ export const layer = Layer.effect(
}
})()
if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) {
if (yield* afs.existsSafe(dir)) {
return resolveEntryPoint(name, path.join(dir, "node_modules", name))
}
const tree = yield* reify({ dir, add: [pkg] })
const first = tree.edgesOut.values().next().value?.to
if (!first) {
const result = resolveEntryPoint(name, path.join(dir, "node_modules", name))
if (Option.isSome(result.entrypoint)) return result
return yield* new InstallFailedError({ add: [pkg], dir })
}
if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
return resolveEntryPoint(first.name, first.path)
}, Effect.scoped)
-16
View File
@@ -1,16 +0,0 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { Global } from "@opencode-ai/core/global"
describe("global paths", () => {
test("tmp path is under the system temp directory", () => {
expect(Global.Path.tmp).toBe(path.join(os.tmpdir(), "opencode"))
expect(Global.make().tmp).toBe(Global.Path.tmp)
})
test("tmp path is created on module load", async () => {
expect((await fs.stat(Global.Path.tmp)).isDirectory()).toBe(true)
})
})
-35
View File
@@ -1,12 +1,7 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect, test } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Layer, Option } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { Npm } from "@opencode-ai/core/npm"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { tmpdir } from "./fixture/tmpdir"
const win = process.platform === "win32"
@@ -20,14 +15,6 @@ const writePackage = (dir: string, pkg: Record<string, unknown>) =>
}),
)
const npmLayer = (cache: string) =>
Npm.layer.pipe(
Layer.provide(EffectFlock.layer),
Layer.provide(AppFileSystem.layer),
Layer.provide(Global.layerWith({ cache, state: path.join(cache, "state") })),
Layer.provide(NodeFileSystem.layer),
)
describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => {
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
@@ -42,28 +29,6 @@ describe("Npm.sanitize", () => {
})
})
describe("Npm.add", () => {
test("reifies when package cache directory exists without the package installed", async () => {
await using tmp = await tmpdir()
await fs.mkdir(path.join(tmp.path, "fixture-provider"))
await writePackage(path.join(tmp.path, "fixture-provider"), {
name: "fixture-provider",
main: "index.js",
})
await Bun.write(path.join(tmp.path, "fixture-provider", "index.js"), "export const fixture = true\n")
const spec = `fixture-provider@file:${path.join(tmp.path, "fixture-provider")}`
await fs.mkdir(path.join(tmp.path, "cache", "packages", Npm.sanitize(spec)), { recursive: true })
const entry = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(spec)
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(Option.isSome(entry.entrypoint)).toBe(true)
})
})
describe("Npm.install", () => {
test("respects omit from project .npmrc", async () => {
await using tmp = await tmpdir()
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.14.32",
"version": "1.14.31",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.14.32",
"version": "1.14.31",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.32",
"version": "1.14.31",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.14.32"
version = "1.14.31"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.32",
"version": "1.14.31",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.32",
"version": "1.14.31",
"name": "opencode",
"type": "module",
"license": "MIT",
+2 -2
View File
@@ -198,7 +198,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
| `project` | `bridged` | list, current, git init, update |
| `file` | `bridged` partial | find text/file/symbol, list/content/status |
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
| `workspace` | `bridged` | adapter/list/status/create/remove/session-restore |
| `workspace` | `bridged` | adaptor/list/status/create/remove/session-restore |
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
| `session` | `bridged` | read, lifecycle, prompt, message/part mutations, revert, permission reply |
@@ -290,7 +290,7 @@ This checklist tracks bridge parity only. Checked routes are available through t
### Workspace Routes
- [x] `GET /experimental/workspace/adapter` - list workspace adapters.
- [x] `GET /experimental/workspace/adaptor` - list workspace adaptors.
- [x] `POST /experimental/workspace` - create workspace.
- [x] `GET /experimental/workspace` - list workspaces.
- [x] `GET /experimental/workspace/status` - workspace status.
+1 -1
View File
@@ -353,7 +353,7 @@ piecewise.
- [ ] `src/cli/cmd/tui/event.ts`
- [ ] `src/cli/ui.ts`
- [ ] `src/command/index.ts`
- [x] `src/control-plane/adapters/worktree.ts`
- [x] `src/control-plane/adaptors/worktree.ts`
- [x] `src/control-plane/types.ts`
- [x] `src/control-plane/workspace.ts`
- [ ] `src/file/index.ts`
+4 -2
View File
@@ -1,15 +1,17 @@
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceBootstrap } from "../project/bootstrap"
import { Instance } from "../project/instance"
import { InstanceStore } from "../project/instance-store"
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
return Instance.provide({
directory,
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: async () => {
try {
const result = await cb()
return result
} finally {
await InstanceStore.runtime.runPromise((s) => s.dispose(Instance.current))
await Instance.dispose()
}
},
})
@@ -133,8 +133,6 @@ export function tui(input: {
}
const renderer = await createCliRenderer(rendererConfig(input.config))
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
void renderer.getPalette({ size: 16 }).catch(() => undefined)
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
await render(() => {
@@ -10,7 +10,7 @@ import { errorMessage } from "@/util/error"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
type Adapter = {
type Adaptor = {
type: string
name: string
description: string
@@ -108,26 +108,26 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
const [adapters, setAdapters] = createSignal<Adapter[]>()
const [adaptors, setAdaptors] = createSignal<Adaptor[]>()
onMount(() => {
dialog.setSize("medium")
void (async () => {
const dir = sync.path.directory || sdk.directory
const url = new URL("/experimental/workspace/adapter", sdk.url)
const url = new URL("/experimental/workspace/adaptor", sdk.url)
if (dir) url.searchParams.set("directory", dir)
const res = await sdk
.fetch(url)
.then((x) => x.json() as Promise<Adapter[]>)
.then((x) => x.json() as Promise<Adaptor[]>)
.catch(() => undefined)
if (!res) {
toast.show({
message: "Failed to load workspace adapters",
message: "Failed to load workspace adaptors",
variant: "error",
})
return
}
setAdapters(res)
setAdaptors(res)
})()
})
@@ -142,13 +142,13 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
},
]
}
const list = adapters()
const list = adaptors()
if (!list) {
return [
{
title: "Loading workspaces...",
value: "loading" as const,
description: "Fetching available workspace adapters",
description: "Fetching available workspace adaptors",
},
]
}
@@ -1,5 +1,4 @@
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"
import { useTheme, tint } from "@tui/context/theme"
import * as Sound from "@tui/util/sound"
@@ -555,7 +554,6 @@ function buildIdleState(t: number, ctx: LogoContext): IdleState {
export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = {}) {
const ctx = props.shape ? build(props.shape) : DEFAULT
const { theme } = useTheme()
const renderer = useRenderer()
const [rings, setRings] = createSignal<Ring[]>([])
const [hold, setHold] = createSignal<Hold>()
const [release, setRelease] = createSignal<Release>()
@@ -686,7 +684,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
})
const idleState = createMemo(() => (props.idle ? buildIdleState(frame().t, ctx) : undefined))
const useSubpixelBlocks = () => renderer.capabilities?.rgb === true
const renderLine = (
line: string,
@@ -792,7 +789,7 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
}
// Solid █: render as ▀ so the top pixel (fg) and bottom pixel (bg) can carry independent shimmer values
if (char === "█" && useSubpixelBlocks()) {
if (char === "█") {
return (
<text
fg={shade(inkTop, theme, n + p + e + b)}
@@ -17,7 +17,6 @@ import { MessageID, PartID } from "@/session/schema"
import { createStore, produce, unwrap } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
import { usePromptHistory, type PromptInfo } from "./history"
import { computePromptTraits } from "./traits"
import { assign } from "./part"
import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
@@ -558,11 +557,17 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
input.traits = computePromptTraits({
mode: store.mode,
disabled: !!props.disabled,
autocompleteVisible: !!auto()?.visible,
})
const capture =
store.mode === "normal"
? auto()?.visible
? (["escape", "navigate", "submit", "tab"] as const)
: (["tab"] as const)
: undefined
input.traits = {
capture,
suspend: !!props.disabled || store.mode === "shell",
status: store.mode === "shell" ? "SHELL" : undefined,
}
})
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
@@ -1,31 +0,0 @@
import type { EditorTraits } from "@opentui/core"
export type PromptMode = "normal" | "shell"
export interface PromptTraitsInput {
mode: PromptMode
disabled: boolean
autocompleteVisible: boolean
}
/**
* Compute the textarea editor traits for the prompt.
*
* `traits.suspend` gates the textarea's keybinding actions (backspace,
* delete-word, arrow movement, undo/redo, etc.). Shell mode is an active
* editing mode only `disabled` should suspend the textarea, otherwise
* users can type in shell mode but cannot delete or move the cursor.
*/
export function computePromptTraits(input: PromptTraitsInput): EditorTraits {
const capture =
input.mode === "normal"
? input.autocompleteVisible
? (["escape", "navigate", "submit", "tab"] as const)
: (["tab"] as const)
: undefined
return {
capture,
suspend: input.disabled,
status: input.mode === "shell" ? "SHELL" : undefined,
}
}
@@ -416,16 +416,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
const values = createMemo(() => {
const active = store.themes[store.active]
if (active) {
return resolveTheme(active, store.mode)
}
if (active) return resolveTheme(active, store.mode)
const saved = kv.get("theme")
if (typeof saved === "string") {
const theme = store.themes[saved]
if (theme) {
return resolveTheme(theme, store.mode)
}
if (theme) return resolveTheme(theme, store.mode)
}
return resolveTheme(store.themes.opencode, store.mode)
+3 -2
View File
@@ -2,7 +2,7 @@ import { Installation } from "@/installation"
import { Server } from "@/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { Instance } from "@/project/instance"
import { InstanceStore } from "@/project/instance-store"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
@@ -77,6 +77,7 @@ export const rpc = {
async checkUpgrade(input: { directory: string }) {
await Instance.provide({
directory: input.directory,
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: async () => {
await upgrade().catch(() => {})
},
@@ -88,7 +89,7 @@ export const rpc = {
async shutdown() {
Log.Default.info("worker shutting down")
await InstanceStore.runtime.runPromise((s) => s.disposeAll())
await Instance.disposeAll()
if (server) await server.stop(true)
},
}
+5 -11
View File
@@ -11,9 +11,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { Auth } from "../auth"
import { Env } from "../env"
import { applyEdits, modify } from "jsonc-parser"
import { type InstanceContext } from "../project/instance"
import { InstanceStore } from "../project/instance-store"
import { InstanceRef } from "@/effect/instance-ref"
import { Instance, type InstanceContext } from "../project/instance"
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
import { existsSync } from "fs"
import { GlobalBus } from "@/bus/global"
@@ -25,7 +23,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { containsPath } from "../project/instance-context"
import { InstanceRef } from "@/effect/instance-ref"
import { zod } from "@/util/effect-zod"
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema"
import { ConfigAgent } from "./agent"
@@ -461,7 +459,7 @@ export const layer = Layer.effect(
const pluginScopeForSource = Effect.fnUntraced(function* (source: string) {
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
if (containsPath(source, ctx)) return "local"
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
return "global"
})
@@ -738,16 +736,12 @@ export const layer = Layer.effect(
yield* fs
.writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
.pipe(Effect.orDie)
if (options?.dispose !== false) {
const ctx = yield* InstanceRef
if (ctx) yield* Effect.promise(() => InstanceStore.runtime.runPromise((s) => s.dispose(ctx)))
}
if (options?.dispose !== false) yield* Effect.promise(() => Instance.dispose())
})
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
yield* invalidateGlobal
const task = InstanceStore.runtime
.runPromise((s) => s.disposeAll())
const task = Instance.disposeAll()
.catch(() => undefined)
.finally(() =>
GlobalBus.emit("event", {
@@ -1,45 +0,0 @@
import type { ProjectID } from "@/project/schema"
import type { WorkspaceAdapter, WorkspaceAdapterEntry } from "../types"
import { WorktreeAdapter } from "./worktree"
const BUILTIN: Record<string, WorkspaceAdapter> = {
worktree: WorktreeAdapter,
}
const state = new Map<ProjectID, Map<string, WorkspaceAdapter>>()
export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter {
const custom = state.get(projectID)?.get(type)
if (custom) return custom
const builtin = BUILTIN[type]
if (builtin) return builtin
throw new Error(`Unknown workspace adapter: ${type}`)
}
export async function listAdapters(projectID: ProjectID): Promise<WorkspaceAdapterEntry[]> {
const builtin = await Promise.all(
Object.entries(BUILTIN).map(async ([type, adapter]) => {
return {
type,
name: adapter.name,
description: adapter.description,
}
}),
)
const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adapter]) => ({
type,
name: adapter.name,
description: adapter.description,
}))
return [...builtin, ...custom]
}
// Plugins can be loaded per-project so we need to scope them. If you
// want to install a global one pass `ProjectID.global`
export function registerAdapter(projectID: ProjectID, type: string, adapter: WorkspaceAdapter) {
const adapters = state.get(projectID) ?? new Map<string, WorkspaceAdapter>()
adapters.set(type, adapter)
state.set(projectID, adapters)
}
@@ -0,0 +1,45 @@
import type { ProjectID } from "@/project/schema"
import type { WorkspaceAdaptor, WorkspaceAdaptorEntry } from "../types"
import { WorktreeAdaptor } from "./worktree"
const BUILTIN: Record<string, WorkspaceAdaptor> = {
worktree: WorktreeAdaptor,
}
const state = new Map<ProjectID, Map<string, WorkspaceAdaptor>>()
export function getAdaptor(projectID: ProjectID, type: string): WorkspaceAdaptor {
const custom = state.get(projectID)?.get(type)
if (custom) return custom
const builtin = BUILTIN[type]
if (builtin) return builtin
throw new Error(`Unknown workspace adaptor: ${type}`)
}
export async function listAdaptors(projectID: ProjectID): Promise<WorkspaceAdaptorEntry[]> {
const builtin = await Promise.all(
Object.entries(BUILTIN).map(async ([type, adaptor]) => {
return {
type,
name: adaptor.name,
description: adaptor.description,
}
}),
)
const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adaptor]) => ({
type,
name: adaptor.name,
description: adaptor.description,
}))
return [...builtin, ...custom]
}
// Plugins can be loaded per-project so we need to scope them. If you
// want to install a global one pass `ProjectID.global`
export function registerAdaptor(projectID: ProjectID, type: string, adaptor: WorkspaceAdaptor) {
const adaptors = state.get(projectID) ?? new Map<string, WorkspaceAdaptor>()
adaptors.set(type, adaptor)
state.set(projectID, adaptors)
}
@@ -1,5 +1,5 @@
import { Schema } from "effect"
import { type WorkspaceAdapter, WorkspaceInfo } from "../types"
import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
const WorktreeConfig = Schema.Struct({
name: WorkspaceInfo.fields.name,
@@ -13,7 +13,7 @@ async function loadWorktree() {
return { AppRuntime, Worktree }
}
export const WorktreeAdapter: WorkspaceAdapter = {
export const WorktreeAdaptor: WorkspaceAdaptor = {
name: "Worktree",
description: "Create a git worktree",
async configure(info) {
+3 -3
View File
@@ -17,12 +17,12 @@ export const WorkspaceInfo = Schema.Struct({
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type WorkspaceInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceInfo>>
export const WorkspaceAdapterEntry = Schema.Struct({
export const WorkspaceAdaptorEntry = Schema.Struct({
type: Schema.String,
name: Schema.String,
description: Schema.String,
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type WorkspaceAdapterEntry = Schema.Schema.Type<typeof WorkspaceAdapterEntry>
export type WorkspaceAdaptorEntry = Schema.Schema.Type<typeof WorkspaceAdaptorEntry>
export type Target =
| {
@@ -35,7 +35,7 @@ export type Target =
headers?: HeadersInit
}
export type WorkspaceAdapter = {
export type WorkspaceAdaptor = {
name: string
description: string
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
@@ -16,7 +16,7 @@ import { Filesystem } from "@/util/filesystem"
import { ProjectID } from "@/project/schema"
import { Slug } from "@opencode-ai/core/util/slug"
import { WorkspaceTable } from "./workspace.sql"
import { getAdapter } from "./adapters"
import { getAdaptor } from "./adaptors"
import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
import { WorkspaceID } from "./schema"
import { Session } from "@/session/session"
@@ -25,7 +25,6 @@ import { SessionID } from "@/session/schema"
import { errorData } from "@/util/error"
import { waitEvent } from "./util"
import { WorkspaceContext } from "./workspace-context"
import { EffectBridge } from "@/effect/bridge"
import { NonNegativeInt, withStatics } from "@/util/schema"
import { zod as effectZod, zodObject } from "@/util/effect-zod"
@@ -336,8 +335,8 @@ export const layer = Layer.effect(
})
const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) {
const adapter = getAdapter(space.projectID, space.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
const adaptor = getAdaptor(space.projectID, space.type)
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space)))
if (target.type === "local") return
@@ -420,8 +419,8 @@ export const layer = Layer.effect(
const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) {
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
const adapter = getAdapter(space.projectID, space.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
const adaptor = getAdaptor(space.projectID, space.type)
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space)))
if (target.type === "local") {
setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error")
@@ -459,9 +458,9 @@ export const layer = Layer.effect(
const create = Effect.fn("Workspace.create")(function* (input: CreateInput) {
const id = WorkspaceID.ascending(input.id)
const adapter = getAdapter(input.projectID, input.type)
const config = yield* EffectBridge.fromPromise(() =>
adapter.configure({ ...input, id, name: Slug.create(), directory: null }),
const adaptor = getAdaptor(input.projectID, input.type)
const config = yield* Effect.promise(() =>
Promise.resolve(adaptor.configure({ ...input, id, name: Slug.create(), directory: null })),
)
const info: Info = {
@@ -497,7 +496,7 @@ export const layer = Layer.effect(
OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES,
}
yield* EffectBridge.fromPromise(() => adapter.create(config, env))
yield* Effect.promise(() => adaptor.create(config, env))
yield* Effect.all(
[
waitEvent({
@@ -532,8 +531,8 @@ export const layer = Layer.effect(
workspaceID: input.workspaceID,
})
const adapter = getAdapter(space.projectID, space.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
const adaptor = getAdaptor(space.projectID, space.type)
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space)))
yield* sync.run(Session.Event.Updated, {
sessionID: input.sessionID,
@@ -725,14 +724,14 @@ export const layer = Layer.effect(
yield* stopSync(id)
const info = fromRow(row)
yield* Effect.catchCause(
yield* Effect.catch(
Effect.gen(function* () {
const adapter = getAdapter(info.projectID, row.type)
yield* EffectBridge.fromPromise(() => adapter.remove(info))
const adaptor = getAdaptor(info.projectID, row.type)
yield* Effect.tryPromise(() => Promise.resolve(adaptor.remove(info)))
}),
() =>
Effect.sync(() => {
log.error("adapter not available when removing workspace", { type: row.type })
log.error("adaptor not available when removing workspace", { type: row.type })
}),
)
@@ -39,7 +39,6 @@ import { Command } from "@/command"
import { Truncate } from "@/tool/truncate"
import { ToolRegistry } from "@/tool/registry"
import { Format } from "@/format"
import { InstanceStore } from "@/project/instance-store"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
import { Workspace } from "@/control-plane/workspace"
@@ -91,7 +90,6 @@ export const AppLayer = Layer.mergeAll(
Truncate.defaultLayer,
ToolRegistry.defaultLayer,
Format.defaultLayer,
InstanceStore.defaultLayer,
Project.defaultLayer,
Vcs.defaultLayer,
Workspace.defaultLayer,
-19
View File
@@ -21,25 +21,6 @@ function restore<R>(instance: InstanceContext | undefined, workspace: WorkspaceI
return fn()
}
/**
* Bridge from Effect into a Promise-returning JS callback while installing
* legacy `Instance.context` and `WorkspaceContext` AsyncLocalStorage for
* the duration of the callback. Effect's `InstanceRef`/`WorkspaceRef` do
* not propagate across async/await boundaries inside `Effect.promise(() =>
* async fn)` callbacks that re-enter Effect via `AppRuntime.runPromise`,
* but Node's AsyncLocalStorage does. Use this whenever an Effect crosses
* into JS that may itself spawn new Effect runtimes (workspace adapters,
* legacy plugins, etc.).
*
* Mirrors `Effect.promise` but restores legacy ALS first.
*/
export const fromPromise = <T>(fn: () => Promise<T> | T): Effect.Effect<T> =>
Effect.gen(function* () {
const instance = yield* InstanceRef
const workspace = yield* WorkspaceRef
return yield* Effect.promise(() => Promise.resolve(restore(instance, workspace, () => fn())))
})
export function make(): Effect.Effect<Shape> {
return Effect.gen(function* () {
const ctx = yield* Effect.context()
+10 -15
View File
@@ -1,4 +1,4 @@
import { Effect, Fiber, Layer, ManagedRuntime } from "effect"
import { Effect, Layer, ManagedRuntime } from "effect"
import * as Context from "effect/Context"
import { Instance } from "@/project/instance"
import { LocalContext } from "@/util/local-context"
@@ -24,20 +24,15 @@ export function attachWith<A, E, R>(effect: Effect.Effect<A, E, R>, refs: Refs):
}
export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
const workspace = WorkspaceContext.workspaceID
const instance = (() => {
try {
return Instance.current
} catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err
}
})()
if (instance && workspace !== undefined) return attachWith(effect, { instance, workspace })
const fiber = Fiber.getCurrent()
return attachWith(effect, {
instance: instance ?? (fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined),
workspace: workspace ?? (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined),
})
try {
return attachWith(effect, {
instance: Instance.current,
workspace: WorkspaceContext.workspaceID,
})
} catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err
}
return effect
}
export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
+3 -3
View File
@@ -10,7 +10,7 @@ import fuzzysort from "fuzzysort"
import ignore from "ignore"
import path from "path"
import { Global } from "@opencode-ai/core/global"
import { containsPath } from "../project/instance-context"
import { Instance } from "../project/instance"
import * as Log from "@opencode-ai/core/util/log"
import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep"
@@ -507,7 +507,7 @@ export const layer = Layer.effect(
const ctx = yield* InstanceState.context
const full = path.join(ctx.directory, file)
if (!containsPath(full, ctx)) {
if (!Instance.containsPath(full, ctx)) {
throw new Error("Access denied: path escapes project directory")
}
@@ -587,7 +587,7 @@ export const layer = Layer.effect(
}
const resolved = dir ? path.join(ctx.directory, dir) : ctx.directory
if (!containsPath(resolved, ctx)) {
if (!Instance.containsPath(resolved, ctx)) {
throw new Error("Access denied: path escapes project directory")
}
+7 -2
View File
@@ -12,7 +12,7 @@ import { Process } from "@/util/process"
import { spawn as lspspawn } from "./launch"
import { Effect, Layer, Context, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { containsPath } from "@/project/instance-context"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { NonNegativeInt, withStatics } from "@/util/schema"
import { zod, ZodOverride } from "@/util/effect-zod"
@@ -221,7 +221,12 @@ export const layer = Layer.effect(
const getClients = Effect.fnUntraced(function* (file: string) {
const ctx = yield* InstanceState.context
if (!containsPath(file, ctx)) return [] as LSPClient.Info[]
if (
!AppFileSystem.contains(ctx.directory, file) &&
(ctx.worktree === "/" || !AppFileSystem.contains(ctx.worktree, file))
) {
return [] as LSPClient.Info[]
}
const s = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
const extension = path.parse(file).ext || file
+5 -5
View File
@@ -3,7 +3,7 @@ import type {
PluginInput,
Plugin as PluginInstance,
PluginModule,
WorkspaceAdapter as PluginWorkspaceAdapter,
WorkspaceAdaptor as PluginWorkspaceAdaptor,
} from "@opencode-ai/plugin"
import { Config } from "@/config/config"
import { Bus } from "../bus"
@@ -24,8 +24,8 @@ import { InstanceState } from "@/effect/instance-state"
import { errorMessage } from "@/util/error"
import { PluginLoader } from "./loader"
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
import { registerAdapter } from "@/control-plane/adapters"
import type { WorkspaceAdapter } from "@/control-plane/types"
import { registerAdaptor } from "@/control-plane/adaptors"
import type { WorkspaceAdaptor } from "@/control-plane/types"
const log = Log.create({ service: "plugin" })
@@ -138,8 +138,8 @@ export const layer = Layer.effect(
worktree: ctx.worktree,
directory: ctx.directory,
experimental_workspace: {
register(type: string, adapter: PluginWorkspaceAdapter) {
registerAdapter(ctx.project.id, type, adapter as WorkspaceAdapter)
register(type: string, adaptor: PluginWorkspaceAdaptor) {
registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor)
},
},
get serverUrl(): URL {
+29 -63
View File
@@ -8,71 +8,37 @@ import * as Vcs from "./vcs"
import { Bus } from "../bus"
import { Command } from "../command"
import { InstanceState } from "@/effect/instance-state"
import * as Log from "@opencode-ai/core/util/log"
import { FileWatcher } from "@/file/watcher"
import { ShareNext } from "@/share/share-next"
import { Context, Effect, Layer } from "effect"
import * as Effect from "effect/Effect"
import { Config } from "@/config/config"
export interface Interface {
readonly run: Effect.Effect<void>
}
export const InstanceBootstrap = Effect.gen(function* () {
const ctx = yield* InstanceState.context
Log.Default.info("bootstrapping", { directory: ctx.directory })
// everything depends on config so eager load it for nice traces
yield* Config.Service.use((svc) => svc.get())
// Plugin can mutate config so it has to be initialized before anything else.
yield* Plugin.Service.use((svc) => svc.init())
yield* Effect.all(
[
LSP.Service,
ShareNext.Service,
Format.Service,
File.Service,
FileWatcher.Service,
Vcs.Service,
Snapshot.Service,
].map((s) => Effect.forkDetach(s.use((i) => i.init()))),
).pipe(Effect.withSpan("InstanceBootstrap.init"))
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceBootstrap") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
// Yield each bootstrap dep at layer init so `run` itself has R = never.
// This breaks the circular declaration loop through Config → Instance → InstanceStore
// (instance-store.ts only yields this Service tag, never the impl-side services).
const bus = yield* Bus.Service
const config = yield* Config.Service
const file = yield* File.Service
const fileWatcher = yield* FileWatcher.Service
const format = yield* Format.Service
const lsp = yield* LSP.Service
const plugin = yield* Plugin.Service
const shareNext = yield* ShareNext.Service
const snapshot = yield* Snapshot.Service
const vcs = yield* Vcs.Service
const run = Effect.gen(function* () {
const ctx = yield* InstanceState.context
yield* Effect.logInfo("bootstrapping", { directory: ctx.directory })
// everything depends on config so eager load it for nice traces
yield* config.get()
// Plugin can mutate config so it has to be initialized before anything else.
yield* plugin.init()
yield* Effect.all(
[lsp, shareNext, format, file, fileWatcher, vcs, snapshot].map((s) => Effect.forkDetach(s.init())),
).pipe(Effect.withSpan("InstanceBootstrap.init"))
const projectID = ctx.project.id
yield* bus.subscribeCallback(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {
Project.setInitialized(projectID)
}
})
}).pipe(Effect.withSpan("InstanceBootstrap"))
return Service.of({ run })
}),
)
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide([
Bus.layer,
Config.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
Format.defaultLayer,
LSP.defaultLayer,
Plugin.defaultLayer,
Project.defaultLayer,
ShareNext.defaultLayer,
Snapshot.defaultLayer,
Vcs.defaultLayer,
]),
)
export * as InstanceBootstrap from "./bootstrap"
const projectID = ctx.project.id
yield* Bus.Service.use((svc) =>
svc.subscribeCallback(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {
Project.setInitialized(projectID)
}
}),
)
}).pipe(Effect.withSpan("InstanceBootstrap"))
@@ -1,24 +0,0 @@
import { LocalContext } from "@/util/local-context"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import type * as Project from "./project"
export interface InstanceContext {
directory: string
worktree: string
project: Project.Info
}
export const context = LocalContext.create<InstanceContext>("instance")
/**
* Check if a path is within the project boundary.
* Returns true if path is inside ctx.directory OR ctx.worktree.
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
*/
export function containsPath(filepath: string, ctx: InstanceContext): boolean {
if (AppFileSystem.contains(ctx.directory, filepath)) return true
// Non-git projects set worktree to "/" which would match ANY absolute path.
// Skip worktree check in this case to preserve external_directory permissions.
if (ctx.worktree === "/") return false
return AppFileSystem.contains(ctx.worktree, filepath)
}
@@ -1,200 +0,0 @@
import { GlobalBus } from "@/bus/global"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { InstanceRef } from "@/effect/instance-ref"
import { disposeInstance } from "@/effect/instance-registry"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect"
import { type InstanceContext } from "./instance-context"
import * as Project from "./project"
export interface LoadInput<R = never> {
directory: string
/**
* Additional setup to run after the default InstanceBootstrap.
* Mainly used by tests for env-var setup or file writes that need the instance ALS context.
*/
init?: Effect.Effect<void, never, R>
worktree?: string
project?: Project.Info
}
export interface Interface {
readonly load: <R = never>(input: LoadInput<R>) => Effect.Effect<InstanceContext, never, R>
readonly reload: <R = never>(input: LoadInput<R>) => Effect.Effect<InstanceContext, never, R>
readonly dispose: (ctx: InstanceContext) => Effect.Effect<void>
readonly disposeAll: () => Effect.Effect<void>
readonly provide: <A, E, R, R2 = never>(
input: LoadInput<R2>,
effect: Effect.Effect<A, E, R>,
) => Effect.Effect<A, E, R | R2>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceStore") {}
interface Entry {
readonly deferred: Deferred.Deferred<InstanceContext>
}
export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const project = yield* Project.Service
const scope = yield* Scope.Scope
const cache = new Map<string, Entry>()
const boot = <R>(input: LoadInput<R> & { directory: string }) =>
Effect.gen(function* () {
const ctx: InstanceContext =
input.project && input.worktree
? {
directory: input.directory,
worktree: input.worktree,
project: input.project,
}
: yield* project.fromDirectory(input.directory).pipe(
Effect.map((result) => ({
directory: input.directory,
worktree: result.sandbox,
project: result.project,
})),
)
if (input.init) yield* input.init.pipe(Effect.provideService(InstanceRef, ctx))
return ctx
}).pipe(Effect.withSpan("InstanceStore.boot"))
const removeEntry = (directory: string, entry: Entry) =>
Effect.sync(() => {
if (cache.get(directory) !== entry) return false
cache.delete(directory)
return true
})
const completeLoad = <R>(directory: string, input: LoadInput<R>, entry: Entry) =>
Effect.gen(function* () {
const exit = yield* Effect.exit(boot({ ...input, directory }))
if (Exit.isFailure(exit)) yield* removeEntry(directory, entry)
yield* Deferred.done(entry.deferred, exit).pipe(Effect.asVoid)
})
const emitDisposed = (input: { directory: string; project?: string }) =>
Effect.sync(() =>
GlobalBus.emit("event", {
directory: input.directory,
project: input.project,
workspace: WorkspaceContext.workspaceID,
payload: {
type: "server.instance.disposed",
properties: {
directory: input.directory,
},
},
}),
)
const disposeContext = Effect.fn("InstanceStore.disposeContext")(function* (ctx: InstanceContext) {
yield* Effect.logInfo("disposing instance", { directory: ctx.directory })
yield* Effect.promise(() => disposeInstance(ctx.directory))
yield* emitDisposed({ directory: ctx.directory, project: ctx.project.id })
})
const disposeEntry = Effect.fnUntraced(function* (directory: string, entry: Entry, ctx: InstanceContext) {
if (cache.get(directory) !== entry) return false
yield* disposeContext(ctx)
if (cache.get(directory) !== entry) return false
cache.delete(directory)
return true
})
const load = <R>(input: LoadInput<R>): Effect.Effect<InstanceContext, never, R> => {
const directory = AppFileSystem.resolve(input.directory)
return Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const existing = cache.get(directory)
if (existing) return yield* restore(Deferred.await(existing.deferred))
const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() }
cache.set(directory, entry)
yield* Effect.gen(function* () {
yield* Effect.logInfo("creating instance", { directory })
yield* completeLoad(directory, input, entry)
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
return yield* restore(Deferred.await(entry.deferred))
}),
).pipe(Effect.withSpan("InstanceStore.load"))
}
const reload = <R>(input: LoadInput<R>): Effect.Effect<InstanceContext, never, R> => {
const directory = AppFileSystem.resolve(input.directory)
return Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const previous = cache.get(directory)
const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() }
cache.set(directory, entry)
yield* Effect.gen(function* () {
yield* Effect.logInfo("reloading instance", { directory })
if (previous) {
yield* Deferred.await(previous.deferred).pipe(Effect.ignore)
yield* Effect.promise(() => disposeInstance(directory))
yield* emitDisposed({ directory, project: input.project?.id })
}
yield* completeLoad(directory, input, entry)
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
return yield* restore(Deferred.await(entry.deferred))
}),
).pipe(Effect.withSpan("InstanceStore.reload"))
}
const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) {
const entry = cache.get(ctx.directory)
if (!entry) return yield* disposeContext(ctx)
const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit)
if (Exit.isFailure(exit)) return yield* removeEntry(ctx.directory, entry).pipe(Effect.asVoid)
if (exit.value !== ctx) return
yield* disposeEntry(ctx.directory, entry, ctx).pipe(Effect.asVoid)
})
const disposeAllOnce = Effect.fnUntraced(function* () {
yield* Effect.logInfo("disposing all instances")
yield* Effect.forEach(
[...cache.entries()],
(item) =>
Effect.gen(function* () {
const exit = yield* Deferred.await(item[1].deferred).pipe(Effect.exit)
if (Exit.isFailure(exit)) {
yield* Effect.logWarning("instance dispose failed", { key: item[0], cause: exit.cause })
yield* removeEntry(item[0], item[1])
return
}
yield* disposeEntry(item[0], item[1], exit.value)
}),
{ discard: true },
)
})
const cachedDisposeAll = yield* Effect.cachedWithTTL(disposeAllOnce(), Duration.zero)
const disposeAll = Effect.fn("InstanceStore.disposeAll")(function* () {
return yield* cachedDisposeAll
})
const provide = <A, E, R, R2>(input: LoadInput<R2>, effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R | R2> =>
load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx))))
yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore))
return Service.of({
load,
reload,
dispose,
disposeAll,
provide,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer))
export const runtime = makeRuntime(Service, defaultLayer)
export * as InstanceStore from "./instance-store"
+152 -13
View File
@@ -1,16 +1,77 @@
import { Effect } from "effect"
import { context, type InstanceContext } from "./instance-context"
import { InstanceStore } from "./instance-store"
import { GlobalBus } from "@/bus/global"
import { disposeInstance } from "@/effect/instance-registry"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { iife } from "@/util/iife"
import * as Log from "@opencode-ai/core/util/log"
import { LocalContext } from "@/util/local-context"
import * as Project from "./project"
import { WorkspaceContext } from "@/control-plane/workspace-context"
export type { InstanceContext } from "./instance-context"
export type { LoadInput } from "./instance-store"
export interface InstanceContext {
directory: string
worktree: string
project: Project.Info
}
const context = LocalContext.create<InstanceContext>("instance")
const cache = new Map<string, Promise<InstanceContext>>()
const project = makeRuntime(Project.Service, Project.defaultLayer)
const disposal = {
all: undefined as Promise<void> | undefined,
}
function boot(input: { directory: string; init?: () => Promise<any>; worktree?: string; project?: Project.Info }) {
return iife(async () => {
const ctx =
input.project && input.worktree
? {
directory: input.directory,
worktree: input.worktree,
project: input.project,
}
: await project
.runPromise((svc) => svc.fromDirectory(input.directory))
.then(({ project, sandbox }) => ({
directory: input.directory,
worktree: sandbox,
project,
}))
await context.provide(ctx, async () => {
await input.init?.()
})
return ctx
})
}
function track(directory: string, next: Promise<InstanceContext>) {
const task = next.catch((error) => {
if (cache.get(directory) === task) cache.delete(directory)
throw error
})
cache.set(directory, task)
return task
}
export const Instance = {
async provide<R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }): Promise<R> {
const ctx = await InstanceStore.runtime.runPromise((store) =>
store.load({ directory: input.directory, init: input.init }),
)
return context.provide(ctx, async () => input.fn())
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
const directory = AppFileSystem.resolve(input.directory)
let existing = cache.get(directory)
if (!existing) {
Log.Default.info("creating instance", { directory })
existing = track(
directory,
boot({
directory,
init: input.init,
}),
)
}
const ctx = await existing
return context.provide(ctx, async () => {
return input.fn()
})
},
get current() {
return context.use()
@@ -25,6 +86,19 @@ export const Instance = {
return context.use().project
},
/**
* Check if a path is within the project boundary.
* Returns true if path is inside Instance.directory OR Instance.worktree.
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
*/
containsPath(filepath: string, ctx?: InstanceContext) {
const instance = ctx ?? Instance
if (AppFileSystem.contains(instance.directory, filepath)) return true
// Non-git projects set worktree to "/" which would match ANY absolute path.
// Skip worktree check in this case to preserve external_directory permissions.
if (instance.worktree === "/") return false
return AppFileSystem.contains(instance.worktree, filepath)
},
/**
* Captures the current instance ALS context and returns a wrapper that
* restores it when called. Use this for callbacks that fire outside the
@@ -42,10 +116,75 @@ export const Instance = {
restore<R>(ctx: InstanceContext, fn: () => R): R {
return context.provide(ctx, fn)
},
async reload(input: InstanceStore.LoadInput) {
return InstanceStore.runtime.runPromise((store) => store.reload(input))
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
const directory = AppFileSystem.resolve(input.directory)
Log.Default.info("reloading instance", { directory })
await disposeInstance(directory)
cache.delete(directory)
const next = track(directory, boot({ ...input, directory }))
GlobalBus.emit("event", {
directory,
project: input.project?.id,
workspace: WorkspaceContext.workspaceID,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
return await next
},
async dispose() {
return InstanceStore.runtime.runPromise((store) => store.dispose(Instance.current))
const directory = Instance.directory
const project = Instance.project
Log.Default.info("disposing instance", { directory })
await disposeInstance(directory)
cache.delete(directory)
GlobalBus.emit("event", {
directory,
project: project.id,
workspace: WorkspaceContext.workspaceID,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
},
async disposeAll() {
if (disposal.all) return disposal.all
disposal.all = iife(async () => {
Log.Default.info("disposing all instances")
const entries = [...cache.entries()]
for (const [key, value] of entries) {
if (cache.get(key) !== value) continue
const ctx = await value.catch((error) => {
Log.Default.warn("instance dispose failed", { key, error })
return undefined
})
if (!ctx) {
if (cache.get(key) === value) cache.delete(key)
continue
}
if (cache.get(key) !== value) continue
await context.provide(ctx, async () => {
await Instance.dispose()
})
}
}).finally(() => {
disposal.all = undefined
})
return disposal.all
},
}
@@ -2,10 +2,10 @@ import { Hono } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi"
import z from "zod"
import { Effect } from "effect"
import { listAdapters } from "@/control-plane/adapters"
import { listAdaptors } from "@/control-plane/adaptors"
import { Workspace } from "@/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"
import { WorkspaceAdapterEntry } from "@/control-plane/types"
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
import { zodObject } from "@/util/effect-zod"
import { Instance } from "@/project/instance"
import { errors } from "../../error"
@@ -18,24 +18,24 @@ const log = Log.create({ service: "server.workspace" })
export const WorkspaceRoutes = lazy(() =>
new Hono()
.get(
"/adapter",
"/adaptor",
describeRoute({
summary: "List workspace adapters",
description: "List all available workspace adapters for the current project.",
operationId: "experimental.workspace.adapter.list",
summary: "List workspace adaptors",
description: "List all available workspace adaptors for the current project.",
operationId: "experimental.workspace.adaptor.list",
responses: {
200: {
description: "Workspace adapters",
description: "Workspace adaptors",
content: {
"application/json": {
schema: resolver(z.array(zodObject(WorkspaceAdapterEntry))),
schema: resolver(z.array(zodObject(WorkspaceAdaptorEntry))),
},
},
},
},
}),
async (c) => {
return c.json(await listAdapters(Instance.project.id))
return c.json(await listAdaptors(Instance.project.id))
},
)
.post(
@@ -8,7 +8,7 @@ import { SyncEvent } from "@/sync"
import { GlobalBus } from "@/bus/global"
import { AppRuntime } from "@/effect/app-runtime"
import { AsyncQueue } from "@/util/queue"
import { InstanceStore } from "../../project/instance-store"
import { Instance } from "../../project/instance"
import { Installation } from "@/installation"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Log from "@opencode-ai/core/util/log"
@@ -200,7 +200,7 @@ export const GlobalRoutes = lazy(() =>
},
}),
async (c) => {
await InstanceStore.runtime.runPromise((s) => s.disposeAll())
await Instance.disposeAll()
GlobalBus.emit("event", {
directory: "global",
payload: {
@@ -1,8 +0,0 @@
# Instance Route Parity
This directory contains the legacy Hono instance routes and the experimental Effect HttpApi implementation under `httpapi/`. Keep them behaviorally aligned.
- When adding, removing, or changing a legacy Hono route, update the matching Effect HttpApi group and handler in `httpapi/` in the same change unless the route is intentionally unsupported.
- When changing an Effect HttpApi route, verify the legacy Hono route has the same public behavior, request shape, response shape, status codes, and instance/workspace routing semantics.
- Keep OpenAPI/SDK-visible schemas aligned. If a difference is only an OpenAPI generation artifact, prefer fixing the source schema first; use `httpapi/public.ts` normalization only for compatibility shims that cannot be represented cleanly in the source schema.
- Add or update parity coverage in `test/server/httpapi-bridge.test.ts` or the focused HttpApi tests when behavior or schema parity could regress.
@@ -1,5 +1,5 @@
import { Workspace } from "@/control-plane/workspace"
import { WorkspaceAdapterEntry } from "@/control-plane/types"
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
import { NonNegativeInt } from "@/util/schema"
import { Schema, Struct } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
@@ -9,17 +9,14 @@ import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/experimental/workspace"
export const CreatePayload = Schema.Struct({
...Struct.omit(Workspace.CreateInput.fields, ["projectID", "extra"]),
extra: Schema.optional(Workspace.CreateInput.fields.extra),
})
export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"]))
export const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]))
export const SessionRestoreResponse = Schema.Struct({
total: NonNegativeInt,
})
export const WorkspacePaths = {
adapters: `${root}/adapter`,
adaptors: `${root}/adaptor`,
list: root,
status: `${root}/status`,
remove: `${root}/:id`,
@@ -30,13 +27,13 @@ export const WorkspaceApi = HttpApi.make("workspace")
.add(
HttpApiGroup.make("workspace")
.add(
HttpApiEndpoint.get("adapters", WorkspacePaths.adapters, {
success: described(Schema.Array(WorkspaceAdapterEntry), "Workspace adapters"),
HttpApiEndpoint.get("adaptors", WorkspacePaths.adaptors, {
success: described(Schema.Array(WorkspaceAdaptorEntry), "Workspace adaptors"),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.adapter.list",
summary: "List workspace adapters",
description: "List all available workspace adapters for the current project.",
identifier: "experimental.workspace.adaptor.list",
summary: "List workspace adaptors",
description: "List all available workspace adaptors for the current project.",
}),
),
HttpApiEndpoint.get("list", WorkspacePaths.list, {
@@ -1,7 +1,7 @@
import { Config } from "@/config/config"
import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
import { Installation } from "@/installation"
import { InstanceStore } from "@/project/instance-store"
import { Instance } from "@/project/instance"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Log from "@opencode-ai/core/util/log"
import { Effect, Queue, Schema } from "effect"
@@ -68,7 +68,6 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
Effect.gen(function* () {
const config = yield* Config.Service
const installation = yield* Installation.Service
const store = yield* InstanceStore.Service
const health = Effect.fn("GlobalHttpApi.health")(function* () {
return { healthy: true as const, version: InstallationVersion }
@@ -87,7 +86,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
})
const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () {
yield* store.disposeAll()
yield* Effect.promise(() => Instance.disposeAll())
GlobalBus.emit("event", {
directory: "global",
payload: { type: "global.disposed", properties: {} },
@@ -1,5 +1,6 @@
import { AppRuntime } from "@/effect/app-runtime"
import * as InstanceState from "@/effect/instance-state"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Project } from "@/project/project"
import { ProjectID } from "@/project/schema"
import { Effect } from "effect"
@@ -28,6 +29,7 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project",
directory: ctx.directory,
worktree: ctx.directory,
project: next,
init: () => AppRuntime.runPromise(InstanceBootstrap),
})
return next
})
@@ -1,4 +1,4 @@
import { listAdapters } from "@/control-plane/adapters"
import { listAdaptors } from "@/control-plane/adaptors"
import { Workspace } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state"
import { Effect } from "effect"
@@ -10,9 +10,9 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const adapters = Effect.fn("WorkspaceHttpApi.adapters")(function* () {
const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () {
const instance = yield* InstanceState.context
return yield* Effect.promise(() => listAdapters(instance.project.id))
return yield* Effect.promise(() => listAdaptors(instance.project.id))
})
const list = Effect.fn("WorkspaceHttpApi.list")(function* () {
@@ -24,7 +24,6 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
return yield* workspace
.create({
...ctx.payload,
extra: ctx.payload.extra ?? null,
projectID: instance.project.id,
})
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
@@ -52,7 +51,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
})
return handlers
.handle("adapters", adapters)
.handle("adaptors", adaptors)
.handle("list", list)
.handle("create", create)
.handle("status", status)
@@ -1,13 +1,13 @@
import { EffectBridge } from "@/effect/bridge"
import type { InstanceContext } from "@/project/instance"
import { InstanceStore } from "@/project/instance-store"
import type { WorkspaceID } from "@/control-plane/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { WorkspaceRef } from "@/effect/instance-ref"
import { Instance, type InstanceContext } from "@/project/instance"
import { Effect } from "effect"
import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http"
type MarkedInstance = {
ctx: InstanceContext
store: InstanceStore.Interface
bridge: EffectBridge.Shape
workspaceID?: WorkspaceID
}
// Disposal is requested by an endpoint handler, but must run from the outer
@@ -17,9 +17,20 @@ const disposeAfterResponse = new WeakMap<object, MarkedInstance>()
const mark = (ctx: InstanceContext) =>
Effect.gen(function* () {
return { ctx, store: yield* InstanceStore.Service, bridge: yield* EffectBridge.make() }
return { ctx, workspaceID: yield* WorkspaceRef }
})
// Instance.dispose/reload still publish events through legacy ALS helpers.
// Effect request handlers carry these values in services, so bridge them back
// into the legacy contexts only around the lifecycle operation.
const restoreMarked = <A>(marked: MarkedInstance, fn: () => A) =>
Effect.promise(() =>
WorkspaceContext.provide({
workspaceID: marked.workspaceID,
fn: () => Instance.restore(marked.ctx, fn),
}),
)
export const markInstanceForDisposal = (ctx: InstanceContext) =>
Effect.gen(function* () {
const marked = yield* mark(ctx)
@@ -32,11 +43,11 @@ export const markInstanceForDisposal = (ctx: InstanceContext) =>
)
})
export const markInstanceForReload = (ctx: InstanceContext, next: InstanceStore.LoadInput) =>
export const markInstanceForReload = (ctx: InstanceContext, next: Parameters<typeof Instance.reload>[0]) =>
Effect.gen(function* () {
const marked = yield* mark(ctx)
return yield* HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.as(Effect.uninterruptible(marked.bridge.run(marked.store.reload(next))), response),
Effect.as(Effect.uninterruptible(restoreMarked(marked, () => Instance.reload(next))), response),
)
})
@@ -47,6 +58,6 @@ export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) =>
const marked = disposeAfterResponse.get(request.source)
if (!marked) return response
disposeAfterResponse.delete(request.source)
yield* Effect.uninterruptible(marked.bridge.run(marked.store.dispose(marked.ctx)))
yield* Effect.uninterruptible(restoreMarked(marked, () => Instance.dispose()))
return response
})
@@ -1,6 +1,9 @@
import { WorkspaceRef } from "@/effect/instance-ref"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceBootstrap } from "@/project/bootstrap"
import { InstanceStore } from "@/project/instance-store"
import { Instance } from "@/project/instance"
import type { InstanceContext } from "@/project/instance"
import { Filesystem } from "@/util/filesystem"
import { Effect, Layer } from "effect"
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
@@ -21,33 +24,32 @@ function decode(input: string): string {
}
}
function makeInstanceContext(directory: string): Effect.Effect<InstanceContext> {
return Effect.promise(() =>
Instance.provide({
directory: Filesystem.resolve(decode(directory)),
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: () => Instance.current,
}),
)
}
function provideInstanceContext<E>(
effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E>,
store: InstanceStore.Interface,
bootstrap: InstanceBootstrap.Interface,
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext> {
return Effect.gen(function* () {
const route = yield* WorkspaceRouteContext
return yield* store.provide(
{ directory: decode(route.directory), init: bootstrap.run },
effect.pipe(Effect.provideService(WorkspaceRef, route.workspaceID)),
const ctx = yield* makeInstanceContext(route.directory)
return yield* effect.pipe(
Effect.provideService(InstanceRef, ctx),
Effect.provideService(WorkspaceRef, route.workspaceID),
)
})
}
export const instanceContextLayer = Layer.effect(
export const instanceContextLayer = Layer.succeed(
InstanceContextMiddleware,
Effect.gen(function* () {
const store = yield* InstanceStore.Service
const bootstrap = yield* InstanceBootstrap.Service
return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store, bootstrap))
}),
InstanceContextMiddleware.of((effect) => provideInstanceContext(effect)),
)
export const instanceRouterMiddleware = HttpRouter.middleware()(
Effect.gen(function* () {
const store = yield* InstanceStore.Service
const bootstrap = yield* InstanceBootstrap.Service
return (effect) => provideInstanceContext(effect, store, bootstrap)
}),
)
export const instanceRouterMiddleware = HttpRouter.middleware()((effect) => provideInstanceContext(effect))
@@ -1,8 +1,8 @@
import { getAdapter } from "@/control-plane/adapters"
import { getAdaptor } from "@/control-plane/adaptors"
import { WorkspaceID } from "@/control-plane/schema"
import type { Target } from "@/control-plane/types"
import { Workspace } from "@/control-plane/workspace"
import { EffectBridge } from "@/effect/bridge"
import { Instance } from "@/project/instance"
import { Session } from "@/session/session"
import { HttpApiProxy } from "./proxy"
import * as Fence from "@/server/fence"
@@ -43,6 +43,14 @@ export class WorkspaceRoutingMiddleware extends HttpApiMiddleware.Service<
}
>()("@opencode/ExperimentalHttpApiWorkspaceRouting") {}
function currentDirectory(): string {
try {
return Instance.directory
} catch {
return process.cwd()
}
}
function requestURL(request: HttpServerRequest.HttpServerRequest): URL {
return new URL(request.url, "http://localhost")
}
@@ -57,7 +65,7 @@ function selectedWorkspaceID(url: URL, sessionWorkspaceID?: WorkspaceID): Worksp
}
function defaultDirectory(request: HttpServerRequest.HttpServerRequest, url: URL): string {
return url.searchParams.get("directory") || request.headers["x-opencode-directory"] || process.cwd()
return url.searchParams.get("directory") || request.headers["x-opencode-directory"] || currentDirectory()
}
function shouldStayOnControlPlane(request: HttpServerRequest.HttpServerRequest, url: URL): boolean {
@@ -80,8 +88,10 @@ function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServe
}
function resolveTarget(workspace: Workspace.Info): Effect.Effect<Target> {
const adapter = getAdapter(workspace.projectID, workspace.type)
return EffectBridge.fromPromise(() => adapter.target(workspace))
return Effect.gen(function* () {
const adaptor = yield* Effect.sync(() => getAdaptor(workspace.projectID, workspace.type))
return yield* Effect.promise(() => Promise.resolve(adaptor.target(workspace)))
})
}
function proxyRemote(
@@ -39,7 +39,6 @@ type OpenApiSchema = {
maximum?: number
minimum?: number
oneOf?: OpenApiSchema[]
pattern?: string
prefixItems?: OpenApiSchema[]
properties?: Record<string, OpenApiSchema>
required?: string[]
@@ -75,18 +74,9 @@ const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"])
const QueryBooleanParameters = new Set(["roots", "archived"])
const QueryParameterSchemas = {
"GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 },
"GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" },
"GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
} satisfies Record<string, OpenApiSchema>
const PathParameterSchemas = {
sessionID: { type: "string", pattern: "^ses.*" },
messageID: { type: "string", pattern: "^msg.*" },
partID: { type: "string", pattern: "^prt.*" },
permissionID: { type: "string", pattern: "^per.*" },
ptyID: { type: "string", pattern: "^pty.*" },
} satisfies Record<string, OpenApiSchema>
const LegacyComponentDescriptions = {
LogLevel: "Log level",
ServerConfig: "Server configuration for opencode serve and web commands",
@@ -438,11 +428,6 @@ function fixSelfReferencingComponents(spec: OpenApiSpec) {
/** Strip `{type:"null"}` arms that Effect's `Schema.optional` adds to OpenAPI unions. */
function stripOptionalNull(schema: OpenApiSchema): OpenApiSchema {
if (schema.allOf?.length === 1) {
const [constraint] = schema.allOf
delete schema.allOf
return stripOptionalNull({ ...schema, ...constraint })
}
if (isEmptyObjectUnion(schema)) return { type: "object", properties: {} }
const options = flattenOptions(schema.anyOf ?? schema.oneOf)
if (options) {
@@ -491,40 +476,25 @@ function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] |
}
function normalizeParameter(param: OpenApiParameter, route: string) {
if (!param.schema || typeof param.schema !== "object") return
if (param.in === "path") {
param.schema = pathParameterSchema(route, param.name) ?? stripOptionalNull(param.schema)
if (param.in !== "query" || !param.schema || typeof param.schema !== "object") return
const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas]
if (override) {
param.schema = override
return
}
if (param.in === "query") {
const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas]
if (override) {
param.schema = override
return
}
if (QueryNumberParameters.has(param.name)) {
param.schema = { type: "number" }
return
}
if (QueryBooleanParameters.has(param.name)) {
param.schema = {
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
}
return
if (QueryNumberParameters.has(param.name)) {
param.schema = { type: "number" }
return
}
if (QueryBooleanParameters.has(param.name)) {
param.schema = {
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
}
return
}
param.schema = stripOptionalNull(param.schema)
}
function pathParameterSchema(route: string, name: string) {
if (name in PathParameterSchemas) return PathParameterSchemas[name as keyof typeof PathParameterSchemas]
if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" }
if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" }
if (name === "requestID" && route.startsWith("POST /permission/")) return { type: "string", pattern: "^per.*" }
if (name === "requestID" && route.startsWith("POST /question/")) return { type: "string", pattern: "^que.*" }
return undefined
}
export const PublicApi = OpenCodeHttpApi.annotateMerge(
OpenApi.annotations({
title: "opencode",
@@ -11,16 +11,12 @@ import { Config } from "@/config/config"
import { Command } from "@/command"
import * as Observability from "@opencode-ai/core/effect/observability"
import { File } from "@/file"
import { FileWatcher } from "@/file/watcher"
import { Ripgrep } from "@/file/ripgrep"
import { Format } from "@/format"
import { LSP } from "@/lsp/lsp"
import { MCP } from "@/mcp"
import { Permission } from "@/permission"
import { Installation } from "@/installation"
import { InstanceBootstrap } from "@/project/bootstrap"
import { InstanceStore } from "@/project/instance-store"
import { Plugin } from "@/plugin"
import { Project } from "@/project/project"
import { ProviderAuth } from "@/provider/auth"
import { Provider } from "@/provider/provider"
@@ -35,9 +31,7 @@ import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { SessionShare } from "@/share/session"
import { ShareNext } from "@/share/share-next"
import { Skill } from "@/skill"
import { Snapshot } from "@/snapshot"
import { SyncEvent } from "@/sync"
import { ToolRegistry } from "@/tool/registry"
import { lazy } from "@/util/lazy"
@@ -148,15 +142,11 @@ export function createRoutes(corsOptions?: CorsOptions) {
Command.defaultLayer,
Config.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
Format.defaultLayer,
LSP.defaultLayer,
Installation.defaultLayer,
InstanceBootstrap.defaultLayer,
InstanceStore.defaultLayer,
MCP.defaultLayer,
Permission.defaultLayer,
Plugin.defaultLayer,
Project.defaultLayer,
ProviderAuth.defaultLayer,
Provider.defaultLayer,
@@ -171,8 +161,6 @@ export function createRoutes(corsOptions?: CorsOptions) {
SessionRunState.defaultLayer,
SessionStatus.defaultLayer,
SessionSummary.defaultLayer,
ShareNext.defaultLayer,
Snapshot.defaultLayer,
SyncEvent.defaultLayer,
Skill.defaultLayer,
Todo.defaultLayer,
@@ -6,7 +6,6 @@ import z from "zod"
import { Format } from "@/format"
import { TuiRoutes } from "./tui"
import { Instance } from "@/project/instance"
import { InstanceStore } from "@/project/instance-store"
import { Vcs } from "@/project/vcs"
import { Agent } from "@/agent/agent"
import { Skill } from "@/skill"
@@ -63,7 +62,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
},
}),
async (c) => {
await InstanceStore.runtime.runPromise((s) => s.dispose(Instance.current))
await Instance.dispose()
return c.json(true)
},
)
@@ -1,5 +1,6 @@
import type { MiddlewareHandler } from "hono"
import { Instance } from "@/project/instance"
import { InstanceBootstrap } from "@/project/bootstrap"
import { AppRuntime } from "@/effect/app-runtime"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { WorkspaceContext } from "@/control-plane/workspace-context"
@@ -23,6 +24,7 @@ export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler
async fn() {
return Instance.provide({
directory,
init: () => AppRuntime.runPromise(InstanceBootstrap),
async fn() {
return next()
},
@@ -7,6 +7,7 @@ import z from "zod"
import { ProjectID } from "@/project/schema"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { InstanceBootstrap } from "@/project/bootstrap"
import { AppRuntime } from "@/effect/app-runtime"
import { jsonRequest, runRequest } from "./trace"
@@ -81,7 +82,12 @@ export const ProjectRoutes = lazy(() =>
Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })),
)
if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next)
await Instance.reload({ directory: dir, worktree: dir, project: next })
await Instance.reload({
directory: dir,
worktree: dir,
project: next,
init: () => AppRuntime.runPromise(InstanceBootstrap),
})
return c.json(next)
},
)
+5 -3
View File
@@ -1,10 +1,11 @@
import type { MiddlewareHandler } from "hono"
import type { UpgradeWebSocket } from "hono/ws"
import { getAdapter } from "@/control-plane/adapters"
import { getAdaptor } from "@/control-plane/adaptors"
import { WorkspaceID } from "@/control-plane/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { Workspace } from "@/control-plane/workspace"
import { Flag } from "@opencode-ai/core/flag/flag"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Instance } from "@/project/instance"
import { Session } from "@/session/session"
import { SessionID } from "@/session/schema"
@@ -90,8 +91,8 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
return next()
}
const adapter = getAdapter(workspace.projectID, workspace.type)
const target = await adapter.target(workspace)
const adaptor = getAdaptor(workspace.projectID, workspace.type)
const target = await adaptor.target(workspace)
if (target.type === "local") {
return WorkspaceContext.provide({
@@ -99,6 +100,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
fn: () =>
Instance.provide({
directory: target.directory,
init: () => AppRuntime.runPromise(InstanceBootstrap),
async fn() {
return next()
},
+1 -9
View File
@@ -938,18 +938,10 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
})
}
if (part.type === "reasoning") {
if (differentModel) {
if (part.text.trim().length > 0)
assistantMessage.parts.push({
type: "text",
text: part.text,
})
continue
}
assistantMessage.parts.push({
type: "reasoning",
text: part.text,
providerMetadata: part.metadata,
...(differentModel ? {} : { providerMetadata: part.metadata }),
})
}
}
+3 -3
View File
@@ -142,9 +142,9 @@ const Share = Schema.Struct({
url: Schema.String,
})
// Legacy HTTP accepted negative values here. Keep archive timestamps permissive
// while excluding non-finite values that cannot round-trip through JSON.
export const ArchivedTimestamp = Schema.Finite
// Legacy HTTP accepted any number here, and persisted data may already contain
// negative values. Keep archive timestamps permissive while other clocks stay non-negative.
export const ArchivedTimestamp = Schema.Number
const Time = Schema.Struct({
created: NonNegativeInt,
+3 -3
View File
@@ -94,7 +94,7 @@ Importantly, **sync events automatically re-publish as bus events**. This makes
### Event shape
- The shape of the events are slightly different. A sync event has the `type`, `id`, `seq`, `aggregateID`, and `data` fields. A bus event has the `type` and `properties` fields. `data` and `properties` are largely the same thing. This conversion is automatically handled when the sync system re-published the event through the bus.
- The shape of the events are slightly different. A sync event has the `type`, `id`, `seq`, `aggregateID`, and `data` fields. A bus event has the `type` and `properties` fields. `data` and `properties` are largely the same thing. This conversion is automatically handled when the sync system re-published the event throught the bus.
The reason for this is because sync events need to track more information. I chose not to copy the `properties` naming to more clearly disambiguate the event types.
@@ -112,9 +112,9 @@ The system install projectors in `server/projectors.js`. It calls `SyncEvent.ini
This allows you to "reshape" an event from the sync system before it's published to the bus. This should be avoided, but might be necessary for temporary backwards compat.
The only time we use this is the `session.updated` event. Previously this event contained the entire session object. The sync event only contains the fields updated. We convert the event to contain the full object for backwards compatibility (but ideally we'd remove this).
The only time we use this is the `session.updated` event. Previously this event contained the entire session object. The sync even only contains the fields updated. We convert the event to contain to full object for backwards compatibility (but ideally we'd remove this).
It's very important that types are correct when working with events. Event definitions have a `schema` which carries the definition of the event shape (provided by a zod schema, inferred into a TypeScript type). Examples:
It's very important that types are correct when working with events. Event definitions have a `schema` which carries the defintiion of the event shape (provided by a zod schema, inferred into a TypeScript type). Examples:
```ts
// The schema from `Updated` typechecks the object correctly
+3 -3
View File
@@ -6,7 +6,7 @@ import * as Tool from "./tool"
import path from "path"
import DESCRIPTION from "./bash.txt"
import * as Log from "@opencode-ai/core/util/log"
import { containsPath, type InstanceContext } from "../project/instance-context"
import { Instance, type InstanceContext } from "../project/instance"
import { lazy } from "@/util/lazy"
import { Language, type Node } from "web-tree-sitter"
@@ -386,7 +386,7 @@ export const BashTool = Tool.define(
for (const arg of pathArgs(command, ps)) {
const resolved = yield* argPath(arg, cwd, ps, shell)
log.info("resolved path", { arg, resolved })
if (!resolved || containsPath(resolved, instance)) continue
if (!resolved || Instance.containsPath(resolved, instance)) continue
const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved)
scan.dirs.add(dir)
}
@@ -612,7 +612,7 @@ export const BashTool = Tool.define(
Effect.sync(() => tree.delete()),
)
const scan = yield* collect(tree.rootNode, cwd, ps, shell, executeInstance)
if (!containsPath(cwd, executeInstance)) scan.dirs.add(cwd)
if (!Instance.containsPath(cwd, executeInstance)) scan.dirs.add(cwd)
yield* ask(ctx, scan)
}),
)
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import * as EffectLogger from "@opencode-ai/core/effect/logger"
import { InstanceState } from "@/effect/instance-state"
import type * as Tool from "./tool"
import { containsPath } from "../project/instance-context"
import { Instance } from "../project/instance"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
type Kind = "file" | "directory"
@@ -24,7 +24,7 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec
const ins = yield* InstanceState.context
const full = process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
if (containsPath(full, ins)) return
if (Instance.containsPath(full, ins)) return
const kind = options?.kind ?? "file"
const dir = kind === "directory" ? full : path.dirname(full)
+2 -5
View File
@@ -10,7 +10,7 @@ import DESCRIPTION from "./read.txt"
import { InstanceState } from "@/effect/instance-state"
import { assertExternalDirectoryEffect } from "./external-directory"
import { Instruction } from "../session/instruction"
import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
import { isImageAttachment, isPdfAttachment, sniffAttachmentMime } from "@/util/media"
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
@@ -18,7 +18,6 @@ const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
const MAX_BYTES = 50 * 1024
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
const SAMPLE_BYTES = 4096
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
// `offset` and `limit` were originally `z.coerce.number()` — the runtime
// coercion was useful when the tool was called from a shell but serves no
@@ -221,9 +220,7 @@ export const ReadTool = Tool.define(
const sample = yield* readSample(filepath, Number(stat.size), SAMPLE_BYTES)
const mime = sniffAttachmentMime(sample, AppFileSystem.mimeType(filepath))
const isImage = SUPPORTED_IMAGE_MIMES.has(mime)
if (isImage || isPdfAttachment(mime)) {
if (isImageAttachment(mime) || isPdfAttachment(mime)) {
const bytes = yield* fs.readFile(filepath)
const msg = isPdfAttachment(mime) ? "PDF read successfully" : "Image read successfully"
return {
+2
View File
@@ -2,6 +2,7 @@ import z from "zod"
import { NamedError } from "@opencode-ai/core/util/error"
import { Global } from "@opencode-ai/core/global"
import { Instance } from "../project/instance"
import { InstanceBootstrap } from "../project/bootstrap"
import { Project } from "@/project/project"
import { Database } from "@/storage/db"
import { eq } from "drizzle-orm"
@@ -254,6 +255,7 @@ export const layer: Layer.Layer<
const booted = yield* Effect.promise(() =>
Instance.provide({
directory: info.directory,
init: () => BootstrapRuntime.runPromise(InstanceBootstrap),
fn: () => undefined,
})
.then(() => true)
+3 -21
View File
@@ -1,11 +1,10 @@
import { afterEach, test, expect } from "bun:test"
import { Effect } from "effect"
import path from "path"
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
import { provideInstance, tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Agent } from "../../src/agent/agent"
import { Permission } from "../../src/permission"
import { Global } from "@opencode-ai/core/global"
// Helper to evaluate permission for a tool with wildcard pattern
function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined {
@@ -18,7 +17,7 @@ function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
}
afterEach(async () => {
await disposeAllInstances()
await Instance.disposeAll()
})
test("returns default native agents when no config", async () => {
@@ -84,7 +83,7 @@ test("explore agent denies edit and write", async () => {
})
})
test("explore agent asks for external directories and allows whitelisted external paths", async () => {
test("explore agent asks for external directories and allows Truncate.GLOB", async () => {
const { Truncate } = await import("../../src/tool/truncate")
await using tmp = await tmpdir()
await Instance.provide({
@@ -94,9 +93,6 @@ test("explore agent asks for external directories and allows whitelisted externa
expect(explore).toBeDefined()
expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask")
expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow")
expect(
Permission.evaluate("external_directory", path.join(Global.Path.tmp, "agent-work"), explore!.permission).action,
).toBe("allow")
},
})
})
@@ -519,20 +515,6 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
})
})
test("global tmp directory children are allowed for external_directory", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await load(tmp.path, (svc) => svc.get("build"))
expect(
Permission.evaluate("external_directory", path.join(Global.Path.tmp, "scratch"), build!.permission).action,
).toBe("allow")
expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("ask")
},
})
})
test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => {
const { Truncate } = await import("../../src/tool/truncate")
await using tmp = await tmpdir({
@@ -4,7 +4,7 @@ import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { Instance } from "../../src/project/instance"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const TestEvent = {
@@ -151,7 +151,7 @@ describe("Bus (Effect-native)", () => {
}).pipe(provideInstance(dir))
// Dispose from OUTSIDE the instance scope
yield* Effect.promise(disposeAllInstances)
yield* Effect.promise(() => Instance.disposeAll())
yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds"))
expect(types).toContain("test.effect.ping")
@@ -3,7 +3,7 @@ import { Schema } from "effect"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { Instance } from "../../src/project/instance"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture"
const TestEvent = BusEvent.define("test.integration", Schema.Struct({ value: Schema.Number }))
@@ -12,7 +12,7 @@ function withInstance(directory: string, fn: () => Promise<void>) {
}
describe("Bus integration: acquireRelease subscriber pattern", () => {
afterEach(() => disposeAllInstances())
afterEach(() => Instance.disposeAll())
test("subscriber via callback facade receives events and cleans up on unsub", async () => {
await using tmp = await tmpdir()
@@ -78,7 +78,7 @@ describe("Bus integration: acquireRelease subscriber pattern", () => {
await Bun.sleep(10)
})
await disposeAllInstances()
await Instance.disposeAll()
await Bun.sleep(50)
expect(received).toEqual([1])
+4 -4
View File
@@ -3,7 +3,7 @@ import { Schema } from "effect"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { Instance } from "../../src/project/instance"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture"
const TestEvent = {
Ping: BusEvent.define("test.ping", Schema.Struct({ value: Schema.Number })),
@@ -15,7 +15,7 @@ function withInstance(directory: string, fn: () => Promise<void>) {
}
describe("Bus", () => {
afterEach(() => disposeAllInstances())
afterEach(() => Instance.disposeAll())
describe("publish + subscribe", () => {
test("subscriber is live immediately after subscribe returns", async () => {
@@ -208,8 +208,8 @@ describe("Bus", () => {
await Bun.sleep(10)
})
// disposeAllInstances triggers the finalizer which publishes InstanceDisposed
await disposeAllInstances()
// Instance.disposeAll triggers the finalizer which publishes InstanceDisposed
await Instance.disposeAll()
await Bun.sleep(50)
expect(received).toContain("test.ping")
@@ -1,38 +0,0 @@
import { describe, expect, test } from "bun:test"
import { computePromptTraits } from "../../../../src/cli/cmd/tui/component/prompt/traits"
describe("computePromptTraits", () => {
test("normal mode without autocomplete only captures tab", () => {
const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: false })
expect(traits.capture).toEqual(["tab"])
expect(traits.suspend).toBe(false)
expect(traits.status).toBeUndefined()
})
test("normal mode with autocomplete captures navigation keys", () => {
const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: true })
expect(traits.capture).toEqual(["escape", "navigate", "submit", "tab"])
expect(traits.suspend).toBe(false)
expect(traits.status).toBeUndefined()
})
test("shell mode does not suspend the textarea", () => {
// Suspending the textarea would gate every keybinding action
// (backspace, delete-word-backward, arrow movement, etc.) — see
// @opentui/core 0.2.x TextareaRenderable.handleKeyPress. Shell mode is
// an active editing mode, so suspend must stay off.
const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false })
expect(traits.suspend).toBe(false)
})
test("shell mode disables capture and labels the prompt", () => {
const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false })
expect(traits.capture).toBeUndefined()
expect(traits.status).toBe("SHELL")
})
test("disabled suspends regardless of mode", () => {
expect(computePromptTraits({ mode: "normal", disabled: true, autocompleteVisible: false }).suspend).toBe(true)
expect(computePromptTraits({ mode: "shell", disabled: true, autocompleteVisible: false }).suspend).toBe(true)
})
})
+2 -2
View File
@@ -12,7 +12,7 @@ import { Account } from "../../src/account/account"
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Env } from "../../src/env"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { provideTmpdirInstance } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { testEffect } from "../lib/effect"
@@ -108,7 +108,7 @@ async function check(map: (dir: string) => string) {
},
})
} finally {
await disposeAllInstances()
await Instance.disposeAll()
;(Global.Path as { config: string }).config = prev
await clear()
}
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { getAdapter, registerAdapter } from "../../src/control-plane/adapters"
import { getAdaptor, registerAdaptor } from "../../src/control-plane/adaptors"
import { ProjectID } from "../../src/project/schema"
import type { WorkspaceInfo } from "../../src/control-plane/types"
@@ -15,7 +15,7 @@ function info(projectID: WorkspaceInfo["projectID"], type: string): WorkspaceInf
}
}
function adapter(dir: string) {
function adaptor(dir: string) {
return {
name: dir,
description: dir,
@@ -33,19 +33,19 @@ function adapter(dir: string) {
}
}
describe("control-plane/adapters", () => {
test("isolates custom adapters by project", async () => {
describe("control-plane/adaptors", () => {
test("isolates custom adaptors by project", async () => {
const type = `demo-${Math.random().toString(36).slice(2)}`
const one = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
const two = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
registerAdapter(one, type, adapter("/one"))
registerAdapter(two, type, adapter("/two"))
registerAdaptor(one, type, adaptor("/one"))
registerAdaptor(two, type, adaptor("/two"))
expect(await (await getAdapter(one, type)).target(info(one, type))).toEqual({
expect(await (await getAdaptor(one, type)).target(info(one, type))).toEqual({
type: "local",
directory: "/one",
})
expect(await (await getAdapter(two, type)).target(info(two, type))).toEqual({
expect(await (await getAdaptor(two, type)).target(info(two, type))).toEqual({
type: "local",
directory: "/two",
})
@@ -54,16 +54,16 @@ describe("control-plane/adapters", () => {
test("latest install wins within a project", async () => {
const type = `demo-${Math.random().toString(36).slice(2)}`
const id = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
registerAdapter(id, type, adapter("/one"))
registerAdaptor(id, type, adaptor("/one"))
expect(await (await getAdapter(id, type)).target(info(id, type))).toEqual({
expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({
type: "local",
directory: "/one",
})
registerAdapter(id, type, adapter("/two"))
registerAdaptor(id, type, adaptor("/two"))
expect(await (await getAdapter(id, type)).target(info(id, type))).toEqual({
expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({
type: "local",
directory: "/two",
})
@@ -21,12 +21,12 @@ import { ModelID, ProviderID } from "@/provider/schema"
import { SyncEvent } from "@/sync"
import { EventSequenceTable, EventTable } from "@/sync/event.sql"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
import { provideTmpdirInstance, tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { registerAdapter } from "../../src/control-plane/adapters"
import { registerAdaptor } from "../../src/control-plane/adaptors"
import { WorkspaceID } from "../../src/control-plane/schema"
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types"
import type { Target, WorkspaceAdaptor, WorkspaceInfo } from "../../src/control-plane/types"
import * as WorkspaceOld from "../../src/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"
@@ -53,8 +53,8 @@ type RecordedCreate = {
from?: WorkspaceInfo
}
type RecordedAdapter = {
adapter: WorkspaceAdapter
type RecordedAdaptor = {
adaptor: WorkspaceAdaptor
calls: {
configure: WorkspaceInfo[]
create: RecordedCreate[]
@@ -93,7 +93,7 @@ beforeEach(() => {
afterEach(async () => {
mock.restore()
await disposeAllInstances()
await Instance.disposeAll()
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspacesFlag
restoreEnv()
await resetDatabase()
@@ -165,13 +165,13 @@ function eventuallyEffect(effect: Effect.Effect<void>, timeout = 1500) {
})
}
function recordedAdapter(input: {
function recordedAdaptor(input: {
target: (info: WorkspaceInfo) => Target | Promise<Target>
configure?: (info: WorkspaceInfo) => WorkspaceInfo | Promise<WorkspaceInfo>
create?: (info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo) => Promise<void>
remove?: (info: WorkspaceInfo) => Promise<void>
}): RecordedAdapter {
const calls: RecordedAdapter["calls"] = {
}): RecordedAdaptor {
const calls: RecordedAdaptor["calls"] = {
configure: [],
create: [],
remove: [],
@@ -180,7 +180,7 @@ function recordedAdapter(input: {
return {
calls,
adapter: {
adaptor: {
name: "recorded",
description: "recorded",
configure(info) {
@@ -207,8 +207,8 @@ function recordedAdapter(input: {
}
}
function localAdapter(dir: string, input?: { createDir?: boolean; remove?: (info: WorkspaceInfo) => Promise<void> }) {
return recordedAdapter({
function localAdaptor(dir: string, input?: { createDir?: boolean; remove?: (info: WorkspaceInfo) => Promise<void> }) {
return recordedAdaptor({
configure(info) {
return { ...info, directory: dir }
},
@@ -223,8 +223,8 @@ function localAdapter(dir: string, input?: { createDir?: boolean; remove?: (info
})
}
function remoteAdapter(url: string, input?: { directory?: string | null; headers?: HeadersInit }) {
return recordedAdapter({
function remoteAdaptor(url: string, input?: { directory?: string | null; headers?: HeadersInit }) {
return recordedAdaptor({
configure(info) {
return { ...info, directory: input?.directory ?? info.directory }
},
@@ -429,7 +429,7 @@ describe("workspace-old CRUD", () => {
const workspaceID = WorkspaceID.ascending("wrk_create_local")
const type = unique("create-local")
const targetDir = path.join(dir, "created-local")
const recorded = recordedAdapter({
const recorded = recordedAdaptor({
configure(info) {
return {
...info,
@@ -446,7 +446,7 @@ describe("workspace-old CRUD", () => {
return { type: "local", directory: targetDir }
},
})
registerAdapter(Instance.project.id, type, recorded.adapter)
registerAdaptor(Instance.project.id, type, recorded.adaptor)
const info = await createWorkspace({
id: workspaceID,
@@ -489,17 +489,17 @@ describe("workspace-old CRUD", () => {
test("create propagates configure failures and does not insert a workspace", async () => {
await withInstance(async () => {
const type = unique("configure-failure")
registerAdapter(
registerAdaptor(
Instance.project.id,
type,
recordedAdapter({
recordedAdaptor({
configure() {
throw new Error("configure exploded")
},
target() {
return { type: "local", directory: "/unused" }
},
}).adapter,
}).adaptor,
)
await expect(
@@ -509,10 +509,10 @@ describe("workspace-old CRUD", () => {
})
})
test("create leaves the inserted row when adapter create fails", async () => {
test("create leaves the inserted row when adaptor create fails", async () => {
await withInstance(async () => {
const type = unique("create-failure")
const recorded = recordedAdapter({
const recorded = recordedAdaptor({
async create() {
throw new Error("create exploded")
},
@@ -520,7 +520,7 @@ describe("workspace-old CRUD", () => {
return { type: "local", directory: "/unused" }
},
})
registerAdapter(Instance.project.id, type, recorded.adapter)
registerAdaptor(Instance.project.id, type, recorded.adaptor)
await expect(
createWorkspace({ type, branch: "branch", projectID: Instance.project.id, extra: { x: 1 } }),
@@ -538,8 +538,8 @@ describe("workspace-old CRUD", () => {
await withInstance(async (dir) => {
const type = unique("local-error")
const missing = path.join(dir, "missing-local-target")
const recorded = localAdapter(missing, { createDir: false })
registerAdapter(Instance.project.id, type, recorded.adapter)
const recorded = localAdaptor(missing, { createDir: false })
registerAdaptor(Instance.project.id, type, recorded.adaptor)
const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null })
@@ -576,8 +576,8 @@ describe("workspace-old CRUD", () => {
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const type = unique("remote-create")
const recorded = remoteAdapter(`${url}/base/?ignored=1#hash`, { directory: dir })
registerAdapter(Instance.project.id, type, recorded.adapter)
const recorded = remoteAdaptor(`${url}/base/?ignored=1#hash`, { directory: dir })
registerAdaptor(Instance.project.id, type, recorded.adaptor)
const info = yield* workspace.create({ type, branch: null, projectID: Instance.project.id, extra: null })
@@ -603,11 +603,11 @@ describe("workspace-old CRUD", () => {
})
})
test("remove deletes the workspace, associated sessions, adapter resources, and status", async () => {
test("remove deletes the workspace, associated sessions, adaptor resources, and status", async () => {
await withInstance(async (dir) => {
const type = unique("remove-local")
const recorded = localAdapter(path.join(dir, "remove-local"))
registerAdapter(Instance.project.id, type, recorded.adapter)
const recorded = localAdaptor(path.join(dir, "remove-local"))
registerAdaptor(Instance.project.id, type, recorded.adaptor)
const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null })
const one = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
const two = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
@@ -628,21 +628,21 @@ describe("workspace-old CRUD", () => {
})
})
test("remove still deletes the row when the adapter cannot remove resources", async () => {
test("remove still deletes the row when the adaptor cannot remove resources", async () => {
await withInstance(async () => {
const type = unique("remove-throws")
const info = workspaceInfo(Instance.project.id, type, { id: WorkspaceID.ascending("wrk_remove_throws") })
registerAdapter(
registerAdaptor(
Instance.project.id,
type,
recordedAdapter({
recordedAdaptor({
async remove() {
throw new Error("remove exploded")
},
target() {
return { type: "local", directory: "/unused" }
},
}).adapter,
}).adaptor,
)
insertWorkspace(info)
@@ -661,7 +661,7 @@ describe("workspace-old sync state", () => {
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
attachSessionToWorkspace(session.id, info.id)
insertWorkspace(info)
registerAdapter(Instance.project.id, type, localAdapter(path.join(dir, "flag-disabled")).adapter)
registerAdaptor(Instance.project.id, type, localAdaptor(path.join(dir, "flag-disabled")).adaptor)
startWorkspaceSyncing(Instance.project.id)
await delay(25)
@@ -682,8 +682,8 @@ describe("workspace-old sync state", () => {
await fs.mkdir(withoutSessionDir, { recursive: true })
insertWorkspace(withSession)
insertWorkspace(withoutSession)
registerAdapter(Instance.project.id, withSessionType, localAdapter(withSessionDir).adapter)
registerAdapter(Instance.project.id, withoutSessionType, localAdapter(withoutSessionDir).adapter)
registerAdaptor(Instance.project.id, withSessionType, localAdaptor(withSessionDir).adaptor)
registerAdaptor(Instance.project.id, withoutSessionType, localAdaptor(withoutSessionDir).adaptor)
attachSessionToWorkspace(
(await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id,
withSession.id,
@@ -707,10 +707,10 @@ describe("workspace-old sync state", () => {
const type = unique("missing-local")
const info = workspaceInfo(Instance.project.id, type)
insertWorkspace(info)
registerAdapter(
registerAdaptor(
Instance.project.id,
type,
localAdapter(path.join(dir, "missing-target"), { createDir: false }).adapter,
localAdaptor(path.join(dir, "missing-target"), { createDir: false }).adaptor,
)
attachSessionToWorkspace(
(await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id,
@@ -738,7 +738,7 @@ describe("workspace-old sync state", () => {
const target = path.join(dir, "dedupe-local")
await fs.mkdir(target, { recursive: true })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, localAdapter(target).adapter)
registerAdaptor(Instance.project.id, type, localAdaptor(target).adaptor)
attachSessionToWorkspace(
(await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id,
info.id,
@@ -795,7 +795,7 @@ describe("workspace-old sync state", () => {
const type = unique("remote-start")
const info = workspaceInfo(Instance.project.id, type)
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sync`).adapter)
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sync`).adaptor)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(Instance.project.id)
@@ -850,7 +850,7 @@ describe("workspace-old sync state", () => {
const type = unique("remote-connect-fail")
const info = workspaceInfo(Instance.project.id, type)
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/failed`).adapter)
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/failed`).adaptor)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(Instance.project.id)
@@ -890,7 +890,7 @@ describe("workspace-old sync state", () => {
const type = unique("remote-history-fail")
const info = workspaceInfo(Instance.project.id, type)
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/history-failed`).adapter)
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/history-failed`).adaptor)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(Instance.project.id)
@@ -947,7 +947,7 @@ describe("workspace-old sync state", () => {
const type = unique("history-replay")
const info = workspaceInfo(Instance.project.id, type)
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/history`).adapter)
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/history`).adaptor)
const session = yield* sessionSvc.create({ title: "before history" })
attachSessionToWorkspace(session.id, info.id)
historySessionID = session.id
@@ -1014,7 +1014,7 @@ describe("workspace-old sync state", () => {
const type = unique("sse-forward")
const info = workspaceInfo(Instance.project.id, type)
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sse-forward`).adapter)
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-forward`).adaptor)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(Instance.project.id)
@@ -1095,7 +1095,7 @@ describe("workspace-old sync state", () => {
const type = unique("sse-sync")
const info = workspaceInfo(Instance.project.id, type)
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sse-sync`).adapter)
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-sync`).adaptor)
const session = yield* sessionSvc.create({ title: "before sse" })
attachSessionToWorkspace(session.id, info.id)
sseSessionID = session.id
@@ -1232,7 +1232,7 @@ describe("workspace-old sessionRestore", () => {
const type = unique("restore-missing-session")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, localAdapter(dir).adapter)
registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor)
await expect(
restoreWorkspaceSession({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }),
@@ -1273,13 +1273,13 @@ describe("workspace-old sessionRestore", () => {
const type = unique("restore-remote")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(
registerAdaptor(
Instance.project.id,
type,
remoteAdapter(`${url}/restore/?ignored=1#hash`, {
remoteAdaptor(`${url}/restore/?ignored=1#hash`, {
directory: dir,
headers: { authorization: "Bearer restore" },
}).adapter,
}).adaptor,
)
const session = yield* sessionSvc.create({ title: "restore remote" })
replaceSessionEvents(session.id, 24)
@@ -1353,7 +1353,7 @@ describe("workspace-old sessionRestore", () => {
const type = unique("restore-null-dir")
const info = workspaceInfo(Instance.project.id, type, { directory: null })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/null-dir`, { directory: null }).adapter)
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/null-dir`, { directory: null }).adaptor)
const session = yield* sessionSvc.create({ title: "null dir" })
replaceSessionEvents(session.id, 0)
@@ -1397,7 +1397,7 @@ describe("workspace-old sessionRestore", () => {
const type = unique("restore-remote-fail")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/fail`, { directory: dir }).adapter)
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/fail`, { directory: dir }).adaptor)
const session = yield* sessionSvc.create({ title: "restore fail" })
replaceSessionEvents(session.id, 11)
@@ -1437,7 +1437,7 @@ describe("workspace-old sessionRestore", () => {
const type = unique("restore-local")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, localAdapter(dir).adapter)
registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor)
const session = yield* sessionSvc.create({ title: "restore local" })
replaceSessionEvents(session.id, 20)
@@ -1488,7 +1488,7 @@ describe("workspace-old sessionRestore", () => {
const type = unique("restore-real-events")
const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/real`, { directory: dir }).adapter)
registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/real`, { directory: dir }).adaptor)
const session = yield* sessionSvc.create({ title: "real events" })
for (let i = 0; i < 3; i++) {
const msg = yield* sessionSvc.updateMessage({
@@ -4,7 +4,7 @@ import { $ } from "bun"
import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { Instance } from "../../src/project/instance"
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(CrossSpawnSpawner.defaultLayer)
@@ -19,7 +19,7 @@ const tmpdirGitScoped = Effect.gen(function* () {
})
afterEach(async () => {
await disposeAllInstances()
await Instance.disposeAll()
})
it.live("InstanceState caches values per directory", () =>
@@ -94,7 +94,7 @@ it.live("InstanceState invalidates on disposeAll", () =>
yield* access(state, one)
yield* access(state, two)
yield* Effect.promise(disposeAllInstances)
yield* Effect.promise(() => Instance.disposeAll())
expect(seen.sort()).toEqual([one, two].sort())
}),
@@ -1,12 +1,9 @@
import { expect } from "bun:test"
import { Effect, Layer, Context } from "effect"
import { InstanceRef } from "../../src/effect/instance-ref"
import { makeRuntime } from "../../src/effect/run-service"
import { ProjectID } from "../../src/project/schema"
import { it } from "../lib/effect"
class Shared extends Context.Service<Shared, { readonly id: number }>()("@test/Shared") {}
const testDirectory = "/tmp/opencode-test"
it.live("makeRuntime shares dependent layers through the shared memo map", () =>
Effect.gen(function* () {
@@ -50,40 +47,3 @@ it.live("makeRuntime shares dependent layers through the shared memo map", () =>
expect(n).toBe(1)
}),
)
it.live("makeRuntime inherits InstanceRef from the current fiber", () =>
Effect.gen(function* () {
class NeedsInstance extends Context.Service<
NeedsInstance,
{ readonly directory: () => Effect.Effect<string | undefined> }
>()("@test/NeedsInstance") {}
const runtime = makeRuntime(
NeedsInstance,
Layer.succeed(
NeedsInstance,
NeedsInstance.of({
directory: () =>
Effect.gen(function* () {
return (yield* InstanceRef)?.directory
}),
}),
),
)
const actual = yield* Effect.promise(() => runtime.runPromise((svc) => svc.directory()))
expect(actual).toBe(testDirectory)
}).pipe(
Effect.provideService(InstanceRef, {
directory: testDirectory,
worktree: testDirectory,
project: {
id: ProjectID.global,
worktree: testDirectory,
time: { created: 0, updated: 0 },
sandboxes: [],
},
}),
),
)
+3 -3
View File
@@ -6,10 +6,10 @@ import fs from "fs/promises"
import { File } from "../../src/file"
import { Instance } from "../../src/project/instance"
import { Filesystem } from "@/util/filesystem"
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
import { provideInstance, tmpdir } from "../fixture/fixture"
afterEach(async () => {
await disposeAllInstances()
await Instance.disposeAll()
})
const init = () => run(File.Service.use((svc) => svc.init()))
@@ -936,7 +936,7 @@ describe("file/index Filesystem patterns", () => {
},
})
await disposeAllInstances()
await Instance.disposeAll()
await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8")
await fs.rm(path.join(tmp.path, "before.ts"))
@@ -5,7 +5,6 @@ import fs from "fs/promises"
import { Filesystem } from "@/util/filesystem"
import { File } from "../../src/file"
import { Instance } from "../../src/project/instance"
import { containsPath } from "../../src/project/instance-context"
import { provideInstance, tmpdir } from "../fixture/fixture"
const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
@@ -122,15 +121,15 @@ describe("File.list path traversal protection", () => {
})
})
describe("containsPath", () => {
describe("Instance.containsPath", () => {
test("returns true for path inside directory", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: () => {
expect(containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true)
expect(containsPath(path.join(tmp.path, "src", "file.ts"), Instance.current)).toBe(true)
expect(Instance.containsPath(path.join(tmp.path, "foo.txt"))).toBe(true)
expect(Instance.containsPath(path.join(tmp.path, "src", "file.ts"))).toBe(true)
},
})
})
@@ -144,11 +143,11 @@ describe("containsPath", () => {
directory: subdir,
fn: () => {
// .opencode at worktree root, but we're running from packages/lib
expect(containsPath(path.join(tmp.path, ".opencode", "state"), Instance.current)).toBe(true)
expect(Instance.containsPath(path.join(tmp.path, ".opencode", "state"))).toBe(true)
// sibling package should also be accessible
expect(containsPath(path.join(tmp.path, "packages", "other", "file.ts"), Instance.current)).toBe(true)
expect(Instance.containsPath(path.join(tmp.path, "packages", "other", "file.ts"))).toBe(true)
// worktree root itself
expect(containsPath(tmp.path, Instance.current)).toBe(true)
expect(Instance.containsPath(tmp.path)).toBe(true)
},
})
})
@@ -159,8 +158,8 @@ describe("containsPath", () => {
await Instance.provide({
directory: tmp.path,
fn: () => {
expect(containsPath("/etc/passwd", Instance.current)).toBe(false)
expect(containsPath("/tmp/other-project", Instance.current)).toBe(false)
expect(Instance.containsPath("/etc/passwd")).toBe(false)
expect(Instance.containsPath("/tmp/other-project")).toBe(false)
},
})
})
@@ -171,7 +170,7 @@ describe("containsPath", () => {
await Instance.provide({
directory: tmp.path,
fn: () => {
expect(containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false)
expect(Instance.containsPath(path.join(tmp.path, "..", "escape.txt"))).toBe(false)
},
})
})
@@ -183,8 +182,8 @@ describe("containsPath", () => {
directory: tmp.path,
fn: () => {
expect(Instance.directory).toBe(Instance.worktree)
expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true)
expect(containsPath("/etc/passwd", Instance.current)).toBe(false)
expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true)
expect(Instance.containsPath("/etc/passwd")).toBe(false)
},
})
})
@@ -196,9 +195,9 @@ describe("containsPath", () => {
directory: tmp.path,
fn: () => {
// worktree is "/" for non-git projects, but containsPath should NOT allow all paths
expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true)
expect(containsPath("/etc/passwd", Instance.current)).toBe(false)
expect(containsPath("/tmp/other", Instance.current)).toBe(false)
expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true)
expect(Instance.containsPath("/etc/passwd")).toBe(false)
expect(Instance.containsPath("/tmp/other")).toBe(false)
},
})
})
+2 -2
View File
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { ConfigProvider, Deferred, Effect, Layer, ManagedRuntime, Option } from "effect"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture"
import { Bus } from "../../src/bus"
import { Config } from "@/config/config"
import { FileWatcher } from "../../src/file/watcher"
@@ -147,7 +147,7 @@ function ready(directory: string) {
describeWatcher("FileWatcher", () => {
afterEach(async () => {
await disposeAllInstances()
await Instance.disposeAll()
})
test("publishes root create, update, and delete events", async () => {
+2 -2
View File
@@ -1,9 +1,9 @@
import { rm } from "fs/promises"
import { Instance } from "../../src/project/instance"
import { Database } from "@/storage/db"
import { disposeAllInstances } from "./fixture"
export async function resetDatabase() {
await disposeAllInstances().catch(() => undefined)
await Instance.disposeAll().catch(() => undefined)
Database.close()
await rm(Database.Path, { force: true }).catch(() => undefined)
await rm(`${Database.Path}-wal`, { force: true }).catch(() => undefined)
@@ -9,16 +9,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import type { Config } from "@/config/config"
import { InstanceRef } from "../../src/effect/instance-ref"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { TestLLMServer } from "../lib/llm-server"
// Test helper for tearing down all loaded instances. Used in afterEach hooks.
// Replaces direct Instance.disposeAll() calls so the legacy promise method can be removed.
// IMPORTANT: must use InstanceStore.runtime, not AppRuntime or a test-layer Service —
// Instance.provide loads instances into InstanceStore.runtime's Service cache, and that
// Service is built per-runtime (not shared via memoMap across Effect.runPromise boundaries).
export const disposeAllInstances = () => InstanceStore.runtime.runPromise((s) => s.disposeAll())
// Strip null bytes from paths (defensive fix for CI environment issues)
function sanitizePath(p: string): string {
return p.replace(/\0/g, "")
@@ -2,13 +2,13 @@ import { afterEach, describe, test, expect } from "bun:test"
import { Permission } from "../src/permission"
import { Config } from "@/config/config"
import { Instance } from "../src/project/instance"
import { disposeAllInstances, tmpdir } from "./fixture/fixture"
import { tmpdir } from "./fixture/fixture"
import { AppRuntime } from "../src/effect/app-runtime"
const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
afterEach(async () => {
await disposeAllInstances()
await Instance.disposeAll()
})
describe("Permission.evaluate for permission.task", () => {
@@ -6,7 +6,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Permission } from "../../src/permission"
import { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance"
import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { MessageID, SessionID } from "../../src/session/schema"
@@ -15,7 +15,7 @@ const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, Cross
const it = testEffect(env)
afterEach(async () => {
await disposeAllInstances()
await Instance.disposeAll()
})
const rejectAll = (message?: string) =>
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture"
import { Filesystem } from "@/util/filesystem"
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
@@ -24,7 +24,7 @@ afterAll(() => {
})
afterEach(async () => {
await disposeAllInstances()
await Instance.disposeAll()
})
async function load(dir: string) {
@@ -3,7 +3,7 @@ import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import path from "path"
import { pathToFileURL } from "url"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
@@ -20,7 +20,7 @@ const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
afterEach(async () => {
await disposeAllInstances()
await Instance.disposeAll()
})
afterAll(() => {
@@ -34,7 +34,7 @@ afterAll(() => {
})
describe("plugin.workspace", () => {
it.live("plugin can install a workspace adapter", () =>
it.live("plugin can install a workspace adaptor", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const type = `plug-${Math.random().toString(36).slice(2)}`
@@ -48,7 +48,7 @@ describe("plugin.workspace", () => {
"export default async ({ experimental_workspace }) => {",
` experimental_workspace.register(${JSON.stringify(type)}, {`,
' name: "plug",',
' description: "plugin workspace adapter",',
' description: "plugin workspace adaptor",',
" configure(input) {",
` return { ...input, name: "plug", branch: "plug/main", directory: ${JSON.stringify(space)} }`,
" },",
@@ -1,273 +0,0 @@
import { afterEach, describe, expect } from "bun:test"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Effect, Fiber, Layer } from "effect"
import { InstanceRef } from "../../src/effect/instance-ref"
import { registerDisposer } from "../../src/effect/instance-registry"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer))
afterEach(async () => {
await disposeAllInstances()
})
describe("InstanceStore", () => {
it.live("loads instance context without installing ALS for the caller", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
const ctx = yield* store.load({ directory: dir })
expect(ctx.directory).toBe(dir)
expect(ctx.worktree).toBe(dir)
expect(() => Instance.current).toThrow()
}),
)
it.live("runs load init with InstanceRef provided", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
let initializedDirectory: string | undefined
yield* store.load({
directory: dir,
init: Effect.gen(function* () {
initializedDirectory = (yield* InstanceRef)?.directory
}),
})
expect(initializedDirectory).toBe(dir)
expect(() => Instance.current).toThrow()
}),
)
it.live("caches loaded instance context by directory", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
let initialized = 0
const first = yield* store.load({
directory: dir,
init: Effect.sync(() => {
initialized++
}),
})
const second = yield* store.load({
directory: dir,
init: Effect.sync(() => {
initialized++
}),
})
expect(second).toBe(first)
expect(initialized).toBe(1)
}),
)
it.live("dedupes concurrent loads while init is in flight", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
let initialized = 0
const first = yield* store
.load({
directory: dir,
init: Effect.promise(async () => {
initialized++
started.resolve()
await release.promise
}),
})
.pipe(Effect.forkScoped)
yield* Effect.promise(() => started.promise)
const second = yield* store
.load({
directory: dir,
init: Effect.sync(() => {
initialized++
}),
})
.pipe(Effect.forkScoped)
expect(initialized).toBe(1)
release.resolve()
const [firstCtx, secondCtx] = yield* Effect.all([Fiber.join(first), Fiber.join(second)])
expect(secondCtx).toBe(firstCtx)
expect(initialized).toBe(1)
}),
)
it.live("removes failed loads from the cache", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
let attempts = 0
const failed = yield* store
.load({
directory: dir,
init: Effect.sync(() => {
attempts++
throw new Error("init failed")
}),
})
.pipe(
Effect.as(false),
Effect.catchCause(() => Effect.succeed(true)),
)
expect(failed).toBe(true)
const ctx = yield* store.load({
directory: dir,
init: Effect.sync(() => {
attempts++
}),
})
expect(ctx.directory).toBe(dir)
expect(attempts).toBe(2)
}),
)
it.live("reload replaces the cached context", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
const first = yield* store.load({ directory: dir })
const second = yield* store.reload({ directory: dir })
const cached = yield* store.load({ directory: dir })
expect(second).not.toBe(first)
expect(cached).toBe(second)
}),
)
it.live("stale dispose does not delete an in-flight reload", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
const reloading = Promise.withResolvers<void>()
const releaseReload = Promise.withResolvers<void>()
const disposed: Array<string> = []
const off = registerDisposer(async (directory) => {
disposed.push(directory)
})
yield* Effect.addFinalizer(() => Effect.sync(off))
const first = yield* store.load({ directory: dir })
const reload = yield* store
.reload({
directory: dir,
init: Effect.promise(async () => {
reloading.resolve()
await releaseReload.promise
}),
})
.pipe(Effect.forkScoped)
yield* Effect.promise(() => reloading.promise)
const staleDispose = yield* store.dispose(first).pipe(Effect.forkScoped)
releaseReload.resolve()
const second = yield* Fiber.join(reload)
yield* Fiber.join(staleDispose)
expect(disposed).toEqual([dir])
expect(yield* store.load({ directory: dir })).toBe(second)
}),
)
it.live("dedupes concurrent disposeAll calls", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
const disposing = Promise.withResolvers<void>()
const releaseDispose = Promise.withResolvers<void>()
const disposed: Array<string> = []
const off = registerDisposer(async (directory) => {
disposed.push(directory)
disposing.resolve()
await releaseDispose.promise
})
yield* Effect.addFinalizer(() => Effect.sync(off))
yield* store.load({ directory: dir })
const first = yield* store.disposeAll().pipe(Effect.forkScoped)
yield* Effect.promise(() => disposing.promise)
const second = yield* store.disposeAll().pipe(Effect.forkScoped)
expect(disposed).toEqual([dir])
releaseDispose.resolve()
yield* Effect.all([Fiber.join(first), Fiber.join(second)])
expect(disposed).toEqual([dir])
}),
)
it.live("re-arms disposeAll after completion", () =>
Effect.gen(function* () {
const dir1 = yield* tmpdirScoped({ git: true })
const dir2 = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
const disposed: Array<string> = []
const off = registerDisposer(async (directory) => {
disposed.push(directory)
})
yield* Effect.addFinalizer(() => Effect.sync(off))
yield* store.load({ directory: dir1 })
yield* store.disposeAll()
expect(disposed).toEqual([dir1])
yield* store.load({ directory: dir2 })
yield* store.disposeAll()
expect(disposed).toEqual([dir1, dir2])
}),
)
it.live("keeps Instance.provide as the legacy ALS wrapper", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const directory = yield* Effect.promise(() =>
Instance.provide({
directory: dir,
fn: () => Instance.directory,
}),
)
expect(directory).toBe(dir)
expect(() => Instance.current).toThrow()
}),
)
it.live("does not install legacy ALS around Effect init", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const directory = yield* Effect.promise(() =>
Instance.provide({
directory: dir,
init: Effect.sync(() => {
expect(() => Instance.current).toThrow()
}),
fn: () => Instance.directory,
}),
)
expect(directory).toBe(dir)
}),
)
})
+3 -3
View File
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import fs from "fs/promises"
import path from "path"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture"
import { AppRuntime } from "../../src/effect/app-runtime"
import { FileWatcher } from "../../src/file/watcher"
import { Instance } from "../../src/project/instance"
@@ -85,7 +85,7 @@ function nextBranchUpdate(directory: string, timeout = 10_000) {
describeVcs("Vcs", () => {
afterEach(async () => {
await disposeAllInstances()
await Instance.disposeAll()
})
test("branch() returns current branch name", async () => {
@@ -158,7 +158,7 @@ describeVcs("Vcs", () => {
describe("Vcs diff", () => {
afterEach(async () => {
await disposeAllInstances()
await Instance.disposeAll()
})
test("defaultBranch() falls back to main", async () => {
@@ -6,7 +6,7 @@ import { Cause, Effect, Exit, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Instance } from "../../src/project/instance"
import { Worktree } from "../../src/worktree"
import { disposeAllInstances, provideInstance, provideTmpdirInstance } from "../fixture/fixture"
import { provideInstance, provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer))
@@ -37,7 +37,7 @@ async function waitReady() {
}
describe("Worktree", () => {
afterEach(() => disposeAllInstances())
afterEach(() => Instance.disposeAll())
describe("makeWorktreeInfo", () => {
it.live("returns info with name, branch, and directory", () =>
@@ -45,10 +45,10 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async ()
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("AWS_REGION", "us-east-1")
set("AWS_PROFILE", "default")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
@@ -70,10 +70,10 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async ()
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("AWS_REGION", "eu-west-1")
set("AWS_PROFILE", "default")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
@@ -125,11 +125,11 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("AWS_PROFILE", "")
set("AWS_ACCESS_KEY_ID", "")
set("AWS_BEARER_TOKEN_BEDROCK", "")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
@@ -171,10 +171,10 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("AWS_PROFILE", "default")
set("AWS_ACCESS_KEY_ID", "test-key-id")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
@@ -203,9 +203,9 @@ test("Bedrock: includes custom endpoint in options when specified", async () =>
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("AWS_PROFILE", "default")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
@@ -236,12 +236,12 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async ()
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token")
set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role")
set("AWS_PROFILE", "")
set("AWS_ACCESS_KEY_ID", "")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
@@ -279,9 +279,9 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () =>
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("AWS_PROFILE", "default")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
@@ -316,9 +316,9 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("AWS_PROFILE", "default")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
@@ -352,9 +352,9 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () =>
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("AWS_PROFILE", "default")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
@@ -388,9 +388,9 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("AWS_PROFILE", "default")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
+100 -100
View File
@@ -2,7 +2,7 @@ import { test, expect } from "bun:test"
import { mkdir, unlink } from "fs/promises"
import path from "path"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture"
import { Global } from "@opencode-ai/core/global"
import { Instance } from "../../src/project/instance"
import { Plugin } from "../../src/plugin/index"
@@ -82,9 +82,9 @@ test("provider loaded from env variable", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.anthropic]).toBeDefined()
@@ -137,9 +137,9 @@ test("disabled_providers excludes provider", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.anthropic]).toBeUndefined()
@@ -161,10 +161,10 @@ test("enabled_providers restricts to only listed providers", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
set("OPENAI_API_KEY", "test-openai-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.anthropic]).toBeDefined()
@@ -191,9 +191,9 @@ test("model whitelist filters models for provider", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.anthropic]).toBeDefined()
@@ -222,9 +222,9 @@ test("model blacklist excludes specific models", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.anthropic]).toBeDefined()
@@ -257,9 +257,9 @@ test("custom model alias via config", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.anthropic]).toBeDefined()
@@ -394,9 +394,9 @@ test("env variable takes precedence, config merges options", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "env-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.anthropic]).toBeDefined()
@@ -420,9 +420,9 @@ test("getModel returns model for valid provider/model", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
expect(model).toBeDefined()
@@ -447,9 +447,9 @@ test("getModel throws ModelNotFoundError for invalid model", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow()
},
@@ -500,9 +500,9 @@ test("defaultModel returns first available model when no config set", async () =
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const model = await defaultModel()
expect(model.providerID).toBeDefined()
@@ -525,9 +525,9 @@ test("defaultModel respects config model setting", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const model = await defaultModel()
expect(String(model.providerID)).toBe("anthropic")
@@ -640,9 +640,9 @@ test("model options are merged from existing model", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
@@ -669,9 +669,9 @@ test("provider removed when all models filtered out", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.anthropic]).toBeUndefined()
@@ -692,9 +692,9 @@ test("closest finds model by partial match", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const result = await closest(ProviderID.anthropic, ["sonnet-4"])
expect(result).toBeDefined()
@@ -747,9 +747,9 @@ test("getModel uses realIdByKey for aliased models", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined()
@@ -862,9 +862,9 @@ test("model inherits properties from existing database model", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
@@ -890,9 +890,9 @@ test("disabled_providers prevents loading even with env var", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("OPENAI_API_KEY", "test-openai-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.openai]).toBeUndefined()
@@ -914,10 +914,10 @@ test("enabled_providers with empty array allows no providers", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
set("OPENAI_API_KEY", "test-openai-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(Object.keys(providers).length).toBe(0)
@@ -944,9 +944,9 @@ test("whitelist and blacklist can be combined", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.anthropic]).toBeDefined()
@@ -1053,9 +1053,9 @@ test("getSmallModel returns appropriate small model", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const model = await getSmallModel(ProviderID.anthropic)
expect(model).toBeDefined()
@@ -1078,9 +1078,9 @@ test("getSmallModel respects config small_model override", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const model = await getSmallModel(ProviderID.anthropic)
expect(model).toBeDefined()
@@ -1126,10 +1126,10 @@ test("multiple providers can be configured simultaneously", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-anthropic-key")
set("OPENAI_API_KEY", "test-openai-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.anthropic]).toBeDefined()
@@ -1205,9 +1205,9 @@ test("model alias name defaults to alias key when id differs", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet")
@@ -1245,9 +1245,9 @@ test("provider with multiple env var options only includes apiKey when single en
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("MULTI_ENV_KEY_1", "test-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.make("multi-env")]).toBeDefined()
@@ -1287,9 +1287,9 @@ test("provider with single env var includes apiKey automatically", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("SINGLE_ENV_KEY", "my-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.make("single-env")]).toBeDefined()
@@ -1324,9 +1324,9 @@ test("model cost overrides existing cost values", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
@@ -1403,11 +1403,11 @@ test("disabled_providers and enabled_providers interaction", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-anthropic")
set("OPENAI_API_KEY", "test-openai")
set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
// anthropic: in enabled, not in disabled = allowed
@@ -1561,10 +1561,10 @@ test("provider env fallback - second env var used if first missing", async () =>
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
// Only set fallback, not primary
set("FALLBACK_KEY", "fallback-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
// Provider should load because fallback env var is set
@@ -1586,9 +1586,9 @@ test("getModel returns consistent results", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
@@ -1647,9 +1647,9 @@ test("ModelNotFoundError includes suggestions for typos", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
try {
await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet
@@ -1675,9 +1675,9 @@ test("ModelNotFoundError for provider includes suggestions", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
try {
await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic
@@ -1723,9 +1723,9 @@ test("getProvider returns provider info", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const provider = await getProvider(ProviderID.anthropic)
expect(provider).toBeDefined()
@@ -1747,9 +1747,9 @@ test("closest returns undefined when no partial match found", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"])
expect(result).toBeUndefined()
@@ -1770,9 +1770,9 @@ test("closest checks multiple query terms in order", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
// First term won't match, second will
const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"])
@@ -1842,9 +1842,9 @@ test("provider options are deeply merged", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
// Custom options should be merged
@@ -1880,9 +1880,9 @@ test("custom model inherits npm package from models.dev provider config", async
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("OPENAI_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
const model = providers[ProviderID.openai].models["my-custom-model"]
@@ -1915,9 +1915,9 @@ test("custom model inherits api.url from models.dev provider", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("OPENROUTER_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.openrouter]).toBeDefined()
@@ -2048,9 +2048,9 @@ test("model variants are generated for reasoning models", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
// Claude sonnet 4 has reasoning capability
@@ -2086,9 +2086,9 @@ test("model variants can be disabled via config", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
@@ -2129,9 +2129,9 @@ test("model variants can be customized via config", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
@@ -2168,9 +2168,9 @@ test("disabled key is stripped from variant config", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
@@ -2206,9 +2206,9 @@ test("all variants can be disabled via config", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
@@ -2244,9 +2244,9 @@ test("variant config merges with generated variants", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
@@ -2282,9 +2282,9 @@ test("variants filtered in second pass for database models", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("OPENAI_API_KEY", "test-api-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
const model = providers[ProviderID.openai].models["gpt-5"]
@@ -2386,9 +2386,9 @@ test("Google Vertex: retains baseURL for custom proxy", async () => {
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined()
@@ -2431,9 +2431,9 @@ test("Google Vertex: supports OpenAI compatible models", async () => {
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"]
@@ -2457,11 +2457,11 @@ test("cloudflare-ai-gateway loads with env variables", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("CLOUDFLARE_ACCOUNT_ID", "test-account")
set("CLOUDFLARE_GATEWAY_ID", "test-gateway")
set("CLOUDFLARE_API_TOKEN", "test-token")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
@@ -2489,11 +2489,11 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => {
})
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("CLOUDFLARE_ACCOUNT_ID", "test-account")
set("CLOUDFLARE_GATEWAY_ID", "test-gateway")
set("CLOUDFLARE_API_TOKEN", "test-token")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
@@ -2557,7 +2557,7 @@ test("plugin config providers persist after instance dispose", async () => {
expect(first[ProviderID.make("demo")]).toBeDefined()
expect(first[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined()
await disposeAllInstances()
await Instance.disposeAll()
const second = await Instance.provide({
directory: tmp.path,
@@ -2592,10 +2592,10 @@ test("plugin config enabled and disabled providers are honored", async () => {
await Instance.provide({
directory: tmp.path,
init: Effect.promise(async () => {
init: async () => {
set("ANTHROPIC_API_KEY", "test-anthropic-key")
set("OPENAI_API_KEY", "test-openai-key")
}).pipe(Effect.asVoid),
},
fn: async () => {
const providers = await list()
expect(providers[ProviderID.anthropic]).toBeDefined()
@@ -2,7 +2,7 @@ import { afterEach, test, expect } from "bun:test"
import { Question } from "../../src/question"
import { Instance } from "../../src/project/instance"
import { QuestionID } from "../../src/question/schema"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture"
import { SessionID } from "../../src/session/schema"
import { AppRuntime } from "../../src/effect/app-runtime"
@@ -17,7 +17,7 @@ const reply = (input: { requestID: QuestionID; answers: ReadonlyArray<Question.A
const reject = (id: QuestionID) => AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(id)))
afterEach(async () => {
await disposeAllInstances()
await Instance.disposeAll()
})
/** Reject all pending questions so dangling Deferred fibers don't hang the test. */
@@ -12,7 +12,7 @@ import { ConfigProvider, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { OpenApi } from "effect/unstable/httpapi"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
@@ -119,23 +119,7 @@ type RequestBody = {
function parameterKey(param: unknown): string | undefined {
if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return undefined
if (typeof param.in !== "string" || typeof param.name !== "string") return undefined
return `${param.in}:${param.name}:${"required" in param && param.required === true}:${stableSchema(
"schema" in param ? param.schema : undefined,
)}`
}
function stableSchema(input: unknown): string {
return JSON.stringify(sortSchema(input))
}
function sortSchema(input: unknown): unknown {
if (Array.isArray(input)) return input.map(sortSchema)
if (!input || typeof input !== "object") return input
return Object.fromEntries(
Object.entries(input)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => [key, sortSchema(value)]),
)
return `${param.in}:${param.name}:${"required" in param && param.required === true}`
}
function parameterSchema(input: {
@@ -208,7 +192,7 @@ afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
await disposeAllInstances()
await Instance.disposeAll()
await resetDatabase()
})
@@ -274,18 +258,6 @@ describe("HttpApi server", () => {
})
})
test("matches SDK-affecting request schema details", () => {
const effect = effectOpenApi()
const sessionUpdate = effect.paths["/session/{sessionID}"]?.patch?.requestBody
const sessionUpdateSchema =
typeof sessionUpdate === "object" && sessionUpdate && "content" in sessionUpdate
? sessionUpdate.content?.["application/json"]?.schema
: undefined
const sessionUpdateProperties = sessionUpdateSchema?.properties as Record<string, OpenApiSchema> | undefined
const time = sessionUpdateProperties?.time
expect(time?.properties?.archived).toEqual({ type: "number" })
})
test("documents event routes as server-sent events", () => {
const effect = effectOpenApi()
@@ -6,7 +6,7 @@ import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
@@ -37,7 +37,7 @@ async function waitDisposed(directory: string) {
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await disposeAllInstances()
await Instance.disposeAll()
await resetDatabase()
})
@@ -5,7 +5,7 @@ import { Server } from "../../src/server/server"
import { EventPaths } from "../../src/server/routes/instance/httpapi/event"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
@@ -29,7 +29,7 @@ async function readFirstChunk(response: Response) {
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await disposeAllInstances()
await Instance.disposeAll()
await resetDatabase()
})

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