diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 6c9a4502f670..bc1ce1cd2dc5 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -33,5 +33,6 @@ rubdos shantur simonklee -spider-yamet clawdbot/llm psychosis, spam pinging the team +-terisuke thdxr -toastythebot diff --git a/bun.lock b/bun.lock index d98e37cfb449..c0337c4a6102 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.28", + "version": "1.14.29", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -83,7 +83,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.28", + "version": "1.14.29", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -117,7 +117,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.28", + "version": "1.14.29", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -144,7 +144,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.28", + "version": "1.14.29", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -168,7 +168,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.28", + "version": "1.14.29", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -192,7 +192,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.28", + "version": "1.14.29", "bin": { "opencode": "./bin/opencode", }, @@ -226,7 +226,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.28", + "version": "1.14.29", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -259,7 +259,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.14.28", + "version": "1.14.29", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -303,7 +303,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.28", + "version": "1.14.29", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -332,7 +332,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.28", + "version": "1.14.29", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -348,7 +348,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.28", + "version": "1.14.29", "bin": { "opencode": "./bin/opencode", }, @@ -491,7 +491,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.28", + "version": "1.14.29", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -526,7 +526,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.28", + "version": "1.14.29", "dependencies": { "cross-spawn": "catalog:", }, @@ -541,7 +541,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.28", + "version": "1.14.29", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -576,7 +576,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.28", + "version": "1.14.29", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -625,7 +625,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.28", + "version": "1.14.29", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -677,8 +677,8 @@ }, "catalog": { "@cloudflare/workers-types": "4.20251008.0", - "@effect/opentelemetry": "4.0.0-beta.48", - "@effect/platform-node": "4.0.0-beta.48", + "@effect/opentelemetry": "4.0.0-beta.57", + "@effect/platform-node": "4.0.0-beta.57", "@hono/zod-validator": "0.4.2", "@kobalte/core": "0.13.11", "@lydell/node-pty": "1.2.0-beta.10", @@ -708,7 +708,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", - "effect": "4.0.0-beta.48", + "effect": "4.0.0-beta.57", "fuzzysort": "3.1.0", "hono": "4.10.7", "hono-openapi": "1.1.2", @@ -1071,11 +1071,11 @@ "@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="], - "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.48", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.48" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-vHk/X1vgDrviGcOTHQqzm2D81TtyPE/C7Qdksg5eAdbGpnqL4Dm4lk6PzTReQ0pO1/avIvWqpxy315IURV0Ldw=="], + "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.57", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.57" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-gdjZPEP0QQg4qmI1vd+443kheeQZKytrjJIzCJncy6ZEpyk/SfrqeStLqLXdTRcms3IB0ls0vOV7KNq7YmBRVA=="], - "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.48", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.48", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.48", "ioredis": "^5.7.0" } }, "sha512-8J6H0k9rtbp9O1QvKOyOPRcCTJ8WrR7IzZLJtYFTZ4bXVEEMCTo84h0CRpi7ccpA9t7DLqotip0NeFgiBosNKQ=="], + "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.57", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.57", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.57", "ioredis": "^5.7.0" } }, "sha512-la0xxPSAYOsY0d+uVxEBxok3jYB31iPQmIaZZRUj2SNWqcGGHJc6KorKtI8guqSLuv9FGZ255kBWXRbG6hMeeg=="], - "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.48", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.48" } }, "sha512-wlhcdDHyacydCgiWdM8JwtQkViQhZsC8uJZ9wMoZXYxlCTvqfdzLeWw4A1UVMoq7sS6/KR1aZVeFkUjrqonncQ=="], + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.57", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.57" } }, "sha512-C976X6f+qHUtLSqcqImuCrjhAHnJV17NC2RvvybsAuDfkyIWU4MyiO2XwgiBeijeNupyr1M/KPKnyjtkNxV9Hw=="], "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], @@ -1601,7 +1601,7 @@ "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "protobufjs": "^7.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w=="], - "@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], + "@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA=="], @@ -3035,7 +3035,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@4.0.0-beta.48", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw=="], + "effect": ["effect@4.0.0-beta.57", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], @@ -5595,18 +5595,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=="], - "@opentelemetry/exporter-trace-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - - "@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - - "@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], - - "@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - - "@opentelemetry/sdk-metrics/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - - "@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - "@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=="], "@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="], @@ -5675,6 +5663,10 @@ "@solidjs/start/vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="], + "@standard-community/standard-json/effect": ["effect@4.0.0-beta.48", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw=="], + + "@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.48", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw=="], + "@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], @@ -6593,6 +6585,10 @@ "@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], + "@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], diff --git a/nix/hashes.json b/nix/hashes.json index e52a9e094590..9ec814b8e265 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-U/LZx/D+5JTT1LHSyZkEuqXP/ky7LkHrEYBW5pcVArk=", - "aarch64-linux": "sha256-nGZa04h4y3jbdmf87IRrlQm/E5qYR8lj5OxKgQSR2XU=", - "aarch64-darwin": "sha256-GD8pCHWMBppDaIfRKxhY2m4xWo1OrY3wOmGw+EC71mw=", - "x86_64-darwin": "sha256-KOH1ZB8pdpF7Xer6QIH7rrr9fwF/BZkCTJndPe0wypg=" + "x86_64-linux": "sha256-h2T/LnUnISZZDn9ZQkZ/A59P+6+QdfOlrgl4RXK/vgM=", + "aarch64-linux": "sha256-+DRohG1ZEB/2LtZU90GWoqJkeyu/sW8A8oKT3f/TtQ0=", + "aarch64-darwin": "sha256-k4nsk/WduuxY8HgjRuqzGT9EjEo7V/2mAzBTYee0fZ0=", + "x86_64-darwin": "sha256-3dSvfN2+5lXwOx57x8NSIWbEZ1fp6+1T6bJpAuUNPyk=" } } diff --git a/package.json b/package.json index bcda48fc5978..2e53fab9cc5f 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,8 @@ "packages/slack" ], "catalog": { - "@effect/opentelemetry": "4.0.0-beta.48", - "@effect/platform-node": "4.0.0-beta.48", + "@effect/opentelemetry": "4.0.0-beta.57", + "@effect/platform-node": "4.0.0-beta.57", "@npmcli/arborist": "9.4.0", "@types/bun": "1.3.12", "@types/cross-spawn": "6.0.6", @@ -53,7 +53,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", - "effect": "4.0.0-beta.48", + "effect": "4.0.0-beta.57", "ai": "6.0.168", "cross-spawn": "7.0.6", "hono": "4.10.7", diff --git a/packages/app/package.json b/packages/app/package.json index ce6b12ca3eec..31f8767b5471 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.28", + "version": "1.14.29", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 18c6fef30a9e..bf8138fcdeae 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -82,7 +82,15 @@ declare global { } function QueryProvider(props: ParentProps) { - const client = new QueryClient() + const client = new QueryClient({ + defaultOptions: { + queries: { + refetchOnReconnect: false, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + }, + }) return {props.children} } diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index 98f262ce5a32..9bb36d32d838 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -1,13 +1,12 @@ -import { useMutation } from "@tanstack/solid-query" -import { Component, createEffect, createMemo, on, Show } from "solid-js" -import { createStore } from "solid-js/store" +import { useMutation, useQueryClient } from "@tanstack/solid-query" +import { Component, createMemo, Show } from "solid-js" import { useSync } from "@/context/sync" import { useSDK } from "@/context/sdk" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { Switch } from "@opencode-ai/ui/switch" -import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" +import { loadMcpQuery } from "@/context/global-sync" const statusLabels = { connected: "mcp.status.connected", @@ -20,48 +19,7 @@ export const DialogSelectMcp: Component = () => { const sync = useSync() const sdk = useSDK() const language = useLanguage() - const [state, setState] = createStore({ - done: false, - loading: false, - }) - - createEffect( - on( - () => sync.data.mcp_ready, - (ready, prev) => { - if (!ready && prev) setState("done", false) - }, - { defer: true }, - ), - ) - - createEffect(() => { - if (state.done || state.loading) return - if (sync.data.mcp_ready) { - setState("done", true) - return - } - - setState("loading", true) - void sdk.client.mcp - .status() - .then((result) => { - sync.set("mcp", result.data ?? {}) - sync.set("mcp_ready", true) - setState("done", true) - }) - .catch((err) => { - setState("done", true) - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) - }) - .finally(() => { - setState("loading", false) - }) - }) + const queryClient = useQueryClient() const items = createMemo(() => Object.entries(sync.data.mcp ?? {}) @@ -71,16 +29,10 @@ export const DialogSelectMcp: Component = () => { const toggle = useMutation(() => ({ mutationFn: async (name: string) => { - const status = sync.data.mcp[name] - if (status?.status === "connected") { - await sdk.client.mcp.disconnect({ name }) - } else { - await sdk.client.mcp.connect({ name }) - } - - const result = await sdk.client.mcp.status() - if (result.data) sync.set("mcp", result.data) + if (sync.data.mcp[name]?.status === "connected") await sdk.client.mcp.disconnect({ name }) + else await sdk.client.mcp.connect({ name }) }, + onSuccess: () => queryClient.refetchQueries({ queryKey: loadMcpQuery(sync.directory).queryKey }), })) const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index 0f6a1c1355f0..952e3eac64a0 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -3,7 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { Icon } from "@opencode-ai/ui/icon" import { Switch } from "@opencode-ai/ui/switch" import { Tabs } from "@opencode-ai/ui/tabs" -import { useMutation } from "@tanstack/solid-query" +import { useMutation, useQueryClient } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" import { useNavigate } from "@solidjs/router" import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" @@ -15,6 +15,7 @@ import { useSDK } from "@/context/sdk" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" +import { loadMcpQuery } from "@/context/global-sync" const pollMs = 10_000 @@ -137,14 +138,14 @@ const useMcpToggleMutation = () => { const sync = useSync() const sdk = useSDK() const language = useLanguage() + const queryClient = useQueryClient() return useMutation(() => ({ mutationFn: async (name: string) => { const status = sync.data.mcp[name] await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name })) - const result = await sdk.client.mcp.status() - if (result.data) sync.set("mcp", result.data) }, + onSuccess: () => queryClient.refetchQueries({ queryKey: loadMcpQuery(sync.directory).queryKey }), onError: (err) => { showToast({ variant: "error", @@ -162,14 +163,6 @@ export function StatusPopoverBody(props: { shown: Accessor }) { const dialog = useDialog() const language = useLanguage() const navigate = useNavigate() - const sdk = useSDK() - - const [load, setLoad] = createStore({ - lspDone: false, - lspLoading: false, - mcpDone: false, - mcpLoading: false, - }) const fail = (err: unknown) => { showToast({ @@ -181,40 +174,6 @@ export function StatusPopoverBody(props: { shown: Accessor }) { createEffect(() => { if (!props.shown()) return - - if (!sync.data.mcp_ready && !load.mcpDone && !load.mcpLoading) { - setLoad("mcpLoading", true) - void sdk.client.mcp - .status() - .then((result) => { - sync.set("mcp", result.data ?? {}) - sync.set("mcp_ready", true) - }) - .catch((err) => { - setLoad("mcpDone", true) - fail(err) - }) - .finally(() => { - setLoad("mcpLoading", false) - }) - } - - if (!sync.data.lsp_ready && !load.lspDone && !load.lspLoading) { - setLoad("lspLoading", true) - void sdk.client.lsp - .status() - .then((result) => { - sync.set("lsp", result.data ?? []) - sync.set("lsp_ready", true) - }) - .catch((err) => { - setLoad("lspDone", true) - fail(err) - }) - .finally(() => { - setLoad("lspLoading", false) - }) - } }) let dialogRun = 0 diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 86496bad50c2..2c80f31b19ba 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -14,17 +14,25 @@ import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" import type { InitError } from "../pages/error" import { useGlobalSDK } from "./global-sdk" -import { bootstrapDirectory, bootstrapGlobal, clearProviderRev } from "./global-sync/bootstrap" +import { + bootstrapDirectory, + bootstrapGlobal, + clearProviderRev, + loadGlobalConfigQuery, + loadPathQuery, + loadProjectsQuery, + loadProvidersQuery, +} from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer" -import { createRefreshQueue } from "./global-sync/queue" import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch" import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" import { trimSessions } from "./global-sync/session-trim" import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { formatServerError } from "@/utils/server-errors" -import { queryOptions, skipToken, useQueryClient } from "@tanstack/solid-query" +import { queryOptions, skipToken, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" +import { createRefreshQueue } from "./global-sync/queue" type GlobalStore = { ready: boolean @@ -43,6 +51,18 @@ type GlobalStore = { export const loadSessionsQuery = (directory: string) => queryOptions({ queryKey: [directory, "loadSessions"], queryFn: skipToken }) +export const loadMcpQuery = (directory: string, sdk?: OpencodeClient) => + queryOptions({ + queryKey: [directory, "mcp"], + queryFn: sdk ? () => sdk.mcp.status().then((r) => r.data ?? {}) : skipToken, + }) + +export const loadLspQuery = (directory: string, sdk?: OpencodeClient) => + queryOptions({ + queryKey: [directory, "lsp"], + queryFn: sdk ? () => sdk.lsp.status().then((r) => r.data ?? []) : skipToken, + }) + function createGlobalSync() { const globalSDK = useGlobalSDK() const language = useLanguage() @@ -54,15 +74,34 @@ function createGlobalSync() { const sessionLoads = new Map>() const sessionMeta = new Map() + const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ + queries: [loadGlobalConfigQuery(), loadProvidersQuery(null), loadPathQuery(null), loadProjectsQuery()], + })) + const [globalStore, setGlobalStore] = createStore({ - ready: false, - path: { state: "", config: "", worktree: "", directory: "", home: "" }, + get ready() { + return bootstrap.isPending + }, project: [], session_todo: {}, - provider: { all: [], connected: [], default: {} }, provider_auth: {}, - config: {}, - reload: undefined, + get path() { + const EMPTY = { state: "", config: "", worktree: "", directory: "", home: "" } + if (pathQuery.isLoading) return EMPTY + return pathQuery.data ?? EMPTY + }, + get provider() { + const EMPTY = { all: [], connected: [], default: {} } + if (providerQuery.isLoading) return EMPTY + return providerQuery.data ?? EMPTY + }, + get config() { + if (configQuery.isLoading) return {} + return configQuery.data ?? {} + }, + get reload() { + return updateConfigMutation.isPending ? "pending" : undefined + }, }) const queryClient = useQueryClient() @@ -88,6 +127,22 @@ function createGlobalSync() { return (setGlobalStore as (...args: unknown[]) => unknown)(...input) }) as typeof setGlobalStore + const bootstrap = useQuery(() => ({ + queryKey: ["bootstrap"], + queryFn: async () => { + await bootstrapGlobal({ + globalSDK: globalSDK.client, + requestFailedTitle: language.t("common.requestFailed"), + translate: language.t, + formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), + setGlobalStore: setBootStore, + queryClient, + }) + bootedAt = Date.now() + return bootedAt + }, + })) + const set = ((...input: unknown[]) => { if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) { setProjects(input[1] as Project[] | ((draft: Project[]) => Project[])) @@ -114,10 +169,21 @@ function createGlobalSync() { const queue = createRefreshQueue({ paused, - bootstrap, + bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }), bootstrapInstance, }) + const sdkFor = (directory: string) => { + const cached = sdkCache.get(directory) + if (cached) return cached + const sdk = globalSDK.createClient({ + directory, + throwOnError: true, + }) + sdkCache.set(directory, sdk) + return sdk + } + const children = createChildStoreManager({ owner, isBooting: (directory) => booting.has(directory), @@ -133,19 +199,9 @@ function createGlobalSync() { clearSessionPrefetchDirectory(directory) }, translate: language.t, + getSdk: sdkFor, }) - const sdkFor = (directory: string) => { - const cached = sdkCache.get(directory) - if (cached) return cached - const sdk = globalSDK.createClient({ - directory, - throwOnError: true, - }) - sdkCache.set(directory, sdk) - return sdk - } - async function loadSessions(directory: string) { const pending = sessionLoads.get(directory) if (pending) return pending @@ -264,26 +320,13 @@ function createGlobalSync() { const event = e.details const recent = bootingRoot || Date.now() - bootedAt < 1500 - if (event.type === "session.error") { - const error = event.properties.error - if (error?.name !== "MessageAbortedError") { - console.error("[global-sync] session error", { - scope: directory === "global" ? "global" : "workspace", - directory: directory === "global" ? undefined : directory, - project: directory === "global" ? undefined : getFilename(directory), - sessionID: event.properties.sessionID, - error, - }) - } - } - if (directory === "global") { applyGlobalEvent({ event, project: globalStore.project, refresh: () => { if (recent) return - queue.refresh() + bootstrap.refetch() }, setGlobalProject: setProjects, }) @@ -309,12 +352,7 @@ function createGlobalSync() { setSessionTodo, vcsCache: children.vcsCache.get(directory), loadLsp: () => { - void sdkFor(directory) - .lsp.status() - .then((x) => { - setStore("lsp", x.data ?? []) - setStore("lsp_ready", true) - }) + void queryClient.fetchQuery(loadLspQuery(directory, sdkFor(directory))) }, }) }) @@ -329,23 +367,6 @@ function createGlobalSync() { } }) - async function bootstrap() { - bootingRoot = true - try { - await bootstrapGlobal({ - globalSDK: globalSDK.client, - requestFailedTitle: language.t("common.requestFailed"), - translate: language.t, - formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), - setGlobalStore: setBootStore, - queryClient, - }) - bootedAt = Date.now() - } finally { - bootingRoot = false - } - } - onMount(() => { if (typeof requestAnimationFrame === "function") { eventFrame = requestAnimationFrame(() => { @@ -361,7 +382,6 @@ function createGlobalSync() { void globalSDK.event.start() }, 0) } - void bootstrap() }) const projectApi = { @@ -374,21 +394,10 @@ function createGlobalSync() { }, } - const updateConfig = async (config: Config) => { - setGlobalStore("reload", "pending") - return globalSDK.client.global.config - .update({ config }) - .then(bootstrap) - .then(() => { - queue.refresh() - setGlobalStore("reload", undefined) - queue.refresh() - }) - .catch((error) => { - setGlobalStore("reload", undefined) - throw error - }) - } + const updateConfigMutation = useMutation(() => ({ + mutationFn: (config: Config) => globalSDK.client.global.config.update({ config }), + onSuccess: () => bootstrap.refetch(), + })) return { data: globalStore, @@ -401,8 +410,8 @@ function createGlobalSync() { }, child: children.child, peek: children.peek, - bootstrap, - updateConfig, + // bootstrap, + updateConfig: updateConfigMutation.mutateAsync, project: projectApi, todo: { set: setSessionTodo, diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index a83030fad25f..451531835dec 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -19,6 +19,7 @@ import type { State, VcsCache } from "./types" import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { formatServerError } from "@/utils/server-errors" import { QueryClient, queryOptions, skipToken } from "@tanstack/solid-query" +import { loadMcpQuery } from "../global-sync" type GlobalStore = { ready: boolean @@ -66,6 +67,62 @@ function runAll(list: Array<() => Promise>) { return Promise.allSettled(list.map((item) => item())) } +function showErrors(input: { + errors: unknown[] + title: string + translate: (key: string, vars?: Record) => string + formatMoreCount: (count: number) => string +}) { + if (input.errors.length === 0) return + const message = formatServerError(input.errors[0], input.translate) + const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : "" + showToast({ + variant: "error", + title: input.title, + description: message + more, + }) +} + +export const loadGlobalConfigQuery = ( + sdk?: OpencodeClient, + transform?: (x: Awaited>) => void, +) => + queryOptions({ + queryKey: ["config"], + queryFn: sdk + ? () => + retry(() => + sdk.global.config.get().then((x) => { + transform?.(x) + return x.data! + }), + ) + : skipToken, + }) + +export const loadProjectsQuery = ( + sdk?: OpencodeClient, + transform?: (x: Awaited>["data"]) => void, +) => + queryOptions({ + queryKey: ["project"], + queryFn: sdk + ? () => + retry(() => + sdk.project + .list() + .then((x) => { + return (x.data ?? []) + .filter((p) => !!p?.id) + .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) + .slice() + .sort((a, b) => cmp(a.id, b.id)) + }) + .then(transform), + ) + : skipToken, + }) + export async function bootstrapGlobal(input: { globalSDK: OpencodeClient requestFailedTitle: string @@ -74,53 +131,15 @@ export async function bootstrapGlobal(input: { setGlobalStore: SetStoreFunction queryClient: QueryClient }) { - const fast = [ - () => - retry(() => - input.globalSDK.global.config.get().then((x) => { - input.setGlobalStore("config", reconcile(x.data!, { merge: false })) - }), - ), - ] - const slow = [ + () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.globalSDK)), + () => input.queryClient.fetchQuery(loadProvidersQuery(null, input.globalSDK)), + () => input.queryClient.fetchQuery(loadPathQuery(null, input.globalSDK)), () => - input.queryClient.fetchQuery({ - ...loadProvidersQuery(null), - queryFn: () => - retry(() => - input.globalSDK.provider.list().then((x) => { - input.setGlobalStore("provider", normalizeProviderList(x.data!)) - return null - }), - ), - }), - () => - retry(() => - input.globalSDK.path.get().then((x) => { - input.setGlobalStore("path", x.data!) - }), - ), - () => - retry(() => - input.globalSDK.project.list().then((x) => { - const projects = (x.data ?? []) - .filter((p) => !!p?.id) - .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) - .slice() - .sort((a, b) => cmp(a.id, b.id)) - input.setGlobalStore("project", projects) - }), + input.queryClient.fetchQuery( + loadProjectsQuery(input.globalSDK, (data) => input.setGlobalStore("project", data ?? [])), ), ] - await runAll(fast) - // showErrors({ - // errors: errors(await runAll(fast)), - // title: input.requestFailedTitle, - // translate: input.translate, - // formatMoreCount: input.formatMoreCount, - // }) - await waitForPaint() await runAll(slow) // showErrors({ // errors: errors(), @@ -128,7 +147,6 @@ export async function bootstrapGlobal(input: { // translate: input.translate, // formatMoreCount: input.formatMoreCount, // }) - input.setGlobalStore("ready", true) } function groupBySession(input: T[]) { @@ -179,26 +197,28 @@ function warmSessions(input: { ).then(() => undefined) } -export const loadProvidersQuery = (directory: string | null) => - queryOptions({ queryKey: [directory, "providers"], queryFn: skipToken }) +export const loadProvidersQuery = (directory: string | null, sdk?: OpencodeClient) => + queryOptions({ + queryKey: [directory, "providers"], + queryFn: sdk ? () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))) : skipToken, + }) export const loadAgentsQuery = ( directory: string | null, sdk?: OpencodeClient, transform?: (x: Awaited>) => void, ) => - queryOptions({ + queryOptions({ queryKey: [directory, "agents"], - queryFn: - sdk && transform - ? () => - retry(() => - sdk.app - .agents() - .then(transform) - .then(() => null), - ) - : skipToken, + queryFn: sdk + ? () => + retry(() => + sdk.app.agents().then((x) => { + transform?.(x) + return x.data! + }), + ) + : skipToken, }) export const loadPathQuery = ( @@ -208,16 +228,15 @@ export const loadPathQuery = ( ) => queryOptions({ queryKey: [directory, "path"], - queryFn: - sdk && transform - ? () => - retry(() => - sdk.path.get().then(async (x) => { - transform(x) - return x.data! - }), - ) - : skipToken, + queryFn: sdk + ? () => + retry(() => + sdk.path.get().then(async (x) => { + transform?.(x) + return x.data! + }), + ) + : skipToken, }) export async function bootstrapDirectory(input: { @@ -247,13 +266,6 @@ export async function bootstrapDirectory(input: { if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) { input.setStore("config", reconcile(input.global.config, { merge: false })) } - if (loading || input.store.provider.all.length === 0) { - input.setStore("provider_ready", false) - } - input.setStore("mcp_ready", false) - input.setStore("mcp", {}) - input.setStore("lsp_ready", false) - input.setStore("lsp", []) if (loading) input.setStore("status", "partial") const rev = (providerRev.get(input.directory) ?? 0) + 1 @@ -340,33 +352,15 @@ export async function bootstrapDirectory(input: { }), ), () => Promise.resolve(input.loadSessions(input.directory)), + () => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk)), () => - retry(() => - input.sdk.mcp.status().then((x) => { - input.setStore("mcp", x.data!) - input.setStore("mcp_ready", true) - }), - ), - () => - input.queryClient.ensureQueryData({ - ...loadProvidersQuery(input.directory), - queryFn: () => - retry(() => input.sdk.provider.list()) - .then((x) => { - if (providerRev.get(input.directory) !== rev) return - input.setStore("provider", normalizeProviderList(x.data!)) - input.setStore("provider_ready", true) - }) - .catch((err) => { - if (providerRev.get(input.directory) !== rev) console.error("Failed to refresh provider list", err) - const project = getFilename(input.directory) - showToast({ - variant: "error", - title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(err, input.translate), - }) - }) - .then(() => null), + input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => { + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: input.translate("toast.project.reloadFailed.title", { project }), + description: formatServerError(err, input.translate), + }) }), ].filter(Boolean) as (() => Promise)[] diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index eee763f16dee..24b4a465002d 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -22,6 +22,7 @@ describe("createChildStoreManager", () => { onBootstrap() {}, onDispose() {}, translate: (key) => key, + getSdk: () => null!, }) Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => { diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index f3b613a7f248..d3b82894a46c 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -1,7 +1,7 @@ import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" -import type { VcsInfo } from "@opencode-ai/sdk/v2/client" +import type { OpencodeClient, VcsInfo } from "@opencode-ai/sdk/v2/client" import { DIR_IDLE_TTL_MS, MAX_DIR_STORES, @@ -14,8 +14,9 @@ import { type VcsCache, } from "./types" import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction" -import { useQuery } from "@tanstack/solid-query" -import { loadPathQuery } from "./bootstrap" +import { useQueries } from "@tanstack/solid-query" +import { loadPathQuery, loadProvidersQuery } from "./bootstrap" +import { loadLspQuery, loadMcpQuery } from "../global-sync" export function createChildStoreManager(input: { owner: Owner @@ -24,6 +25,7 @@ export function createChildStoreManager(input: { onBootstrap: (directory: string) => void onDispose: (directory: string) => void translate: (key: string, vars?: Record) => string + getSdk: (directory: string) => OpencodeClient }) { const children: Record, SetStoreFunction]> = {} const vcsCache = new Map() @@ -156,14 +158,27 @@ export function createChildStoreManager(input: { const init = () => createRoot((dispose) => { + const sdk = input.getSdk(directory) + + const initialMeta = meta[0].value const initialIcon = icon[0].value - const pathQuery = useQuery(() => loadPathQuery(directory)) + const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({ + queries: [ + loadPathQuery(directory, sdk), + loadMcpQuery(directory, sdk), + loadLspQuery(directory, sdk), + loadProvidersQuery(directory, sdk), + ], + })) + const child = createStore({ project: "", - projectMeta: undefined, + projectMeta: initialMeta, icon: initialIcon, - provider_ready: false, + get provider_ready() { + return providerQuery.isLoading + }, provider: { all: [], connected: [], default: {} }, config: {}, get path() { @@ -181,10 +196,18 @@ export function createChildStoreManager(input: { todo: {}, permission: {}, question: {}, - mcp_ready: false, - mcp: {}, - lsp_ready: false, - lsp: [], + get mcp_ready() { + return mcpQuery.isLoading + }, + get mcp() { + return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {}) + }, + get lsp_ready() { + return lspQuery.isLoading + }, + get lsp() { + return lspQuery.isLoading ? [] : (lspQuery.data ?? []) + }, vcs: vcsStore.value, limit: 5, message: {}, @@ -207,6 +230,11 @@ export function createChildStoreManager(input: { child[1]("vcs", (value) => value ?? cached) }) + onPersistedInit(meta[2], () => { + if (child[0].projectMeta !== initialMeta) return + child[1]("projectMeta", meta[0].value) + }) + onPersistedInit(icon[2], () => { if (child[0].icon !== initialIcon) return child[1]("icon", icon[0].value) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 97d9cacbbe15..cacc875c54d6 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -391,7 +391,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( ? globalSync.data.project.find((x) => x.id === projectID) : globalSync.data.project.find((x) => x.worktree === project.worktree) - return { ...metadata, ...project } + // Preserve local icon override from per-workspace localStorage cache (childStore.icon). + // Without this, different subdirectories of the same git repo would share the same + // icon from the database instead of using their individual overrides. + const base = { ...metadata, ...project } + if (childStore.icon) { + return { ...base, icon: { ...base.icon, override: childStore.icon } } + } + return base } const roots = createMemo(() => { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index d44dcbf9042c..698c39c207a8 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.28", + "version": "1.14.29", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/app/src/config.ts b/packages/console/app/src/config.ts index 6f11bfa02876..829da5751eb5 100644 --- a/packages/console/app/src/config.ts +++ b/packages/console/app/src/config.ts @@ -9,8 +9,8 @@ export const config = { github: { repoUrl: "https://github.com/anomalyco/opencode", starsFormatted: { - compact: "140K", - full: "140,000", + compact: "150K", + full: "150,000", }, }, diff --git a/packages/console/core/package.json b/packages/console/core/package.json index e8b73d242843..8a0443b9f592 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.28", + "version": "1.14.29", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index b958c3e8f434..dda8212e074c 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.28", + "version": "1.14.29", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 2d57c481ad27..f1988a02eaa6 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.28", + "version": "1.14.29", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index e180df1cbcdd..9a1744c569f9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.28", + "version": "1.14.29", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 0f580ec67b33..80eaaa0f1a52 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.14.28", + "version": "1.14.29", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 9831043a46a1..f8392270bb36 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.28", + "version": "1.14.29", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 7227deb34580..1431e13728e1 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.28", + "version": "1.14.29", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index acd690d7c35b..a64c3005254f 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.28" +version = "1.14.29" 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.28/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.29/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.28/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.29/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.28/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.29/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.28/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.29/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.28/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.29/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 96e8826799d6..da6498cdfbd7 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.28", + "version": "1.14.29", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/.gitignore b/packages/opencode/.gitignore index 2b20d9c3123f..6600814a8c25 100644 --- a/packages/opencode/.gitignore +++ b/packages/opencode/.gitignore @@ -7,3 +7,4 @@ src/provider/models-snapshot.js src/provider/models-snapshot.d.ts script/build-*.ts temporary-*.md +.artifacts diff --git a/packages/opencode/migration/20260428004200_add_session_path/migration.sql b/packages/opencode/migration/20260428004200_add_session_path/migration.sql new file mode 100644 index 000000000000..e3ef6f990001 --- /dev/null +++ b/packages/opencode/migration/20260428004200_add_session_path/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `session` ADD `path` text; \ No newline at end of file diff --git a/packages/opencode/migration/20260428004200_add_session_path/snapshot.json b/packages/opencode/migration/20260428004200_add_session_path/snapshot.json new file mode 100644 index 000000000000..d79324fedf86 --- /dev/null +++ b/packages/opencode/migration/20260428004200_add_session_path/snapshot.json @@ -0,0 +1,1419 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "aaa2ebeb-caa4-478d-8365-4fc595d16856", + "prevIds": ["66cbe0d7-def0-451b-b88a-7608513a9b44"], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_entry", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": ["active_account_id"], + "tableTo": "account", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": ["message_id"], + "tableTo": "message", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_entry_session_id_session_id_fk", + "entityType": "fks", + "table": "session_entry" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": ["aggregate_id"], + "tableTo": "event_sequence", + "columnsTo": ["aggregate_id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": ["email", "url"], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": ["session_id", "position"], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": ["project_id"], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_entry_pk", + "table": "session_entry", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": ["aggregate_id"], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_session_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_session_type_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_time_created_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c569b9b225d2..8d4e53ee4dc0 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.28", + "version": "1.14.29", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/opencode/src/cli/cmd/generate.ts b/packages/opencode/src/cli/cmd/generate.ts index 0531d537c20a..768002957dbd 100644 --- a/packages/opencode/src/cli/cmd/generate.ts +++ b/packages/opencode/src/cli/cmd/generate.ts @@ -1,15 +1,26 @@ import { Server } from "../../server/server" +import { PublicApi } from "../../server/routes/instance/httpapi/public" import type { CommandModule } from "yargs" +import { OpenApi } from "effect/unstable/httpapi" + +type Args = { + httpapi: boolean +} export const GenerateCommand = { command: "generate", - handler: async () => { - const specs = await Server.openapi() + builder: (yargs) => + yargs.option("httpapi", { + type: "boolean", + default: false, + description: "Generate OpenAPI from the experimental Effect HttpApi contract", + }), + handler: async (args) => { + const specs = args.httpapi ? OpenApi.fromApi(PublicApi) : await Server.openapi() for (const item of Object.values(specs.paths)) { for (const method of ["get", "post", "put", "delete", "patch"] as const) { const operation = item[method] if (!operation?.operationId) continue - // @ts-expect-error operation["x-codeSamples"] = [ { lang: "js", @@ -47,4 +58,4 @@ export const GenerateCommand = { }) }) }, -} satisfies CommandModule +} satisfies CommandModule diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 833c8dc8c332..703da1b59d3b 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -736,6 +736,18 @@ function App(props: { onSnapshot?: () => Promise }) { dialog.clear() }, }, + { + title: kv.get("session_directory_filter_enabled", true) + ? "Disable session directory filtering" + : "Enable session directory filtering", + value: "app.toggle.session_directory_filter", + category: "System", + onSelect: async (dialog) => { + kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true)) + await sync.session.refresh() + dialog.clear() + }, + }, { title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping", value: "app.toggle.diffwrap", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 576098178b7b..72d60767bb9a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -32,11 +32,14 @@ export function DialogSessionList() { const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) - const [searchResults, { refetch }] = createResource(search, async (query) => { - if (!query) return undefined - const result = await sdk.client.session.list({ search: query, limit: 30 }) - return result.data ?? [] - }) + const [searchResults, { refetch }] = createResource( + () => ({ query: search(), filter: sync.session.query() }), + async (input) => { + if (!input.query) return undefined + const result = await sdk.client.session.list({ search: input.query, limit: 30, ...input.filter }) + return result.data ?? [] + }, + ) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) const sessions = createMemo(() => searchResults() ?? sync.data.session) diff --git a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts index 40063dc70a2a..00f90857c025 100644 --- a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts +++ b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts @@ -20,6 +20,8 @@ const ZedEditorContentsSchema = z.object({ contents: z.string().nullable(), }) +const utf8 = new TextEncoder() + type ZedEditorRow = z.infer type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number } @@ -45,8 +47,8 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()): .catch(() => undefined) if (text == null) return { type: "unavailable" } - const startOffset = Math.min(row.selection_start, row.selection_end) - const endOffset = Math.max(row.selection_start, row.selection_end) + const startOffset = utf8ByteOffsetToStringIndex(text, Math.min(row.selection_start, row.selection_end)) + const endOffset = utf8ByteOffsetToStringIndex(text, Math.max(row.selection_start, row.selection_end)) return { type: "selection", @@ -158,7 +160,25 @@ function zedWorkspacePaths(value: string | null) { } export function offsetToPosition(text: string, offset: number) { - return offsetsToSelection(text, offset, offset).start + const stringOffset = utf8ByteOffsetToStringIndex(text, offset) + return offsetsToSelection(text, stringOffset, stringOffset).start +} + +function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) { + if (byteOffset <= 0) return 0 + + let bytes = 0 + for (let index = 0; index < text.length; ) { + const codePoint = text.codePointAt(index) + if (codePoint === undefined) return text.length + + const nextIndex = index + (codePoint > 0xffff ? 2 : 1) + bytes += utf8.encode(text.slice(index, nextIndex)).length + if (bytes >= byteOffset) return nextIndex + index = nextIndex + } + + return text.length } function offsetsToSelection(text: string, startOffset: number, endOffset: number) { diff --git a/packages/opencode/src/cli/cmd/tui/context/editor.ts b/packages/opencode/src/cli/cmd/tui/context/editor.ts index aff5f4a6ba3c..531bf4507d20 100644 --- a/packages/opencode/src/cli/cmd/tui/context/editor.ts +++ b/packages/opencode/src/cli/cmd/tui/context/editor.ts @@ -116,6 +116,12 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create reconnect = setTimeout(connect, delay) } + const scheduleZedPoll = () => { + if (closed) return + if (reconnect) clearTimeout(reconnect) + reconnect = setTimeout(connect, 1000) + } + const connect = () => { if (closed) return @@ -145,7 +151,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create .finally(() => { zedSelection = undefined }) - scheduleReconnect() + scheduleZedPoll() return } diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 7b18d7f4ee64..24609dd81e4f 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -30,6 +30,8 @@ import { useArgs } from "./args" import { batch, onMount } from "solid-js" import * as Log from "@opencode-ai/core/util/log" import { emptyConsoleState, type ConsoleState } from "@/config/console-state" +import path from "path" +import { useKV } from "./kv" export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", @@ -107,10 +109,27 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const event = useEvent() const project = useProject() const sdk = useSDK() + const kv = useKV() const fullSyncedSessions = new Set() let syncedWorkspace = project.workspace.current() + function sessionListQuery(): { scope?: "project"; path?: string } { + if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" } + if (!project.data.instance.path.worktree || !project.data.instance.path.directory) return { scope: "project" } + return { + path: path + .relative(path.resolve(project.data.instance.path.worktree), project.data.instance.path.directory) + .replaceAll("\\", "/"), + } + } + + function listSessions() { + return sdk.client.session + .list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000, ...sessionListQuery() }) + .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) + } + event.subscribe((event) => { switch (event.type) { case "server.instance.disposed": @@ -360,10 +379,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ fullSyncedSessions.clear() syncedWorkspace = workspace } - const start = Date.now() - 30 * 24 * 60 * 60 * 1000 - const sessionListPromise = sdk.client.session - .list({ start: start }) - .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) + const projectPromise = project.sync() + const sessionListPromise = projectPromise.then(() => listSessions()) // blocking - include session.list when continuing a session const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true }) @@ -374,7 +391,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ .catch(() => emptyConsoleState) const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true }) const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true }) - const projectPromise = project.sync() const blockingRequests: Promise[] = [ providersPromise, providerListPromise, @@ -479,11 +495,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (match.found) return store.session[match.index] return undefined }, + query() { + return sessionListQuery() + }, async refresh() { - const start = Date.now() - 30 * 24 * 60 * 60 * 1000 - const list = await sdk.client.session - .list({ start }) - .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) + const list = await listSessions() setStore("session", reconcile(list)) }, status(sessionID: string) { diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index d4e643dddaa2..5c26d461e58c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -500,7 +500,8 @@ async function getCustomThemes() { symlink: true, })) { const name = path.basename(item, ".json") - result[name] = await Filesystem.readJson(item) + const theme = await Filesystem.readJson(item) + if (isTheme(theme)) result[name] = theme } } return result diff --git a/packages/opencode/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts index 0aac0d3f5eff..8fa8dee763af 100644 --- a/packages/opencode/src/plugin/github-copilot/models.ts +++ b/packages/opencode/src/plugin/github-copilot/models.ts @@ -58,7 +58,7 @@ function build(key: string, remote: Item, url: string, prev?: Model): Model { const isMsgApi = remote.supported_endpoints?.includes("/v1/messages") - return { + const model: Model = { id: key, providerID: "github-copilot", api: { @@ -107,8 +107,50 @@ function build(key: string, remote: Item, url: string, prev?: Model): Model { release_date: prev?.release_date ?? (remote.version.startsWith(`${remote.id}-`) ? remote.version.slice(remote.id.length + 1) : remote.version), - variants: prev?.variants ?? {}, } + + const efforts = remote.capabilities.supports.reasoning_effort + const variants: NonNullable = {} + if (!isMsgApi && efforts?.length) { + efforts.forEach((effort) => { + variants[effort] = { + reasoningEffort: effort, + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + } + }) + } else { + if (efforts?.length && remote.capabilities.supports.adaptive_thinking) { + efforts.forEach((effort) => { + variants[effort] = { + thinking: { + type: "adaptive", + ...(model.api.id.includes("opus-4.7") ? { display: "summarized" } : {}), + }, + effort, + } + }) + } else if (remote.capabilities.supports.max_thinking_budget) { + const max = remote.capabilities.supports.max_thinking_budget + variants["max"] = { + thinking: { + type: "enabled", + budgetTokens: max - 1, + }, + } + variants["high"] = { + thinking: { + type: "enabled", + budgetTokens: Math.floor(max / 2), + }, + } + } + } + if (Object.keys(variants).length > 0) { + model.variants = variants + } + + return model } export async function get( diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 465d0531082d..0313022c3685 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -127,7 +127,7 @@ export const layer = Layer.effect( Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`, } : undefined, - fetch: async (...args) => (await Server.Default()).app.fetch(...args), + fetch: async (...args) => Server.Default().app.fetch(...args), }) const cfg = yield* config.get() const input: PluginInput = { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 841fd97f082e..c05d05319353 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1358,7 +1358,9 @@ const layer: Layer.Layer< ) delete provider.models[modelID] - model.variants = mapValues(ProviderTransform.variants(model), (v) => v) + if (!model.variants || Object.keys(model.variants).length === 0) { + model.variants = mapValues(ProviderTransform.variants(model), (v) => v) + } const configVariants = configProvider?.models?.[modelID]?.variants if (configVariants && model.variants) { diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 8ec388776905..a8f2fcf30857 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -630,16 +630,17 @@ export function variants(model: Provider.Model): Record [effort, { reasoningEffort: effort }])) - } - } - if (adaptiveEfforts) { + let efforts = [...adaptiveEfforts] + if (model.providerID === "github-copilot") { + if (model.api.id.includes("opus-4.7")) { + efforts = ["medium"] + } + // Efforts currently supported are: low, medium, high + efforts = efforts.filter((v) => v !== "max" && v !== "xhigh") + } return Object.fromEntries( - adaptiveEfforts.map((effort) => [ + efforts.map((effort) => [ effort, { thinking: { @@ -1089,6 +1090,21 @@ export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JS } */ + if (model.providerID === "moonshotai" || model.api.id.toLowerCase().includes("kimi")) { + const sanitizeMoonshot = (obj: unknown): unknown => { + if (obj === null || typeof obj !== "object") return obj + if (Array.isArray(obj)) return obj.map(sanitizeMoonshot) + // Moonshot expands $ref before validation and rejects sibling keywords like description on the same node. + if ("$ref" in obj && typeof obj.$ref === "string") return { $ref: obj.$ref } + const result = Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, sanitizeMoonshot(value)])) + // MFJS does not support tuple-style `items` arrays; it requires one schema object for all array items. + if (Array.isArray(result.items)) result.items = result.items[0] ?? {} + return result + } + + schema = sanitizeMoonshot(schema) as JSONSchema.BaseSchema | JSONSchema7 + } + // Convert integer enums to string enums for Google/Gemini if (model.providerID === "google" || model.api.id.includes("gemini")) { const isPlainObject = (node: unknown): node is Record => diff --git a/packages/opencode/src/server/adapter.bun.ts b/packages/opencode/src/server/adapter.bun.ts index 3e70b97e8afd..b1f3bae27a89 100644 --- a/packages/opencode/src/server/adapter.bun.ts +++ b/packages/opencode/src/server/adapter.bun.ts @@ -1,40 +1,44 @@ import type { Hono } from "hono" import { createBunWebSocket } from "hono/bun" -import type { Adapter } from "./adapter" +import type { Adapter, FetchApp, Opts } from "./adapter" + +function listen(app: FetchApp, opts: Opts, websocket?: ReturnType["websocket"]) { + const start = (port: number) => { + try { + if (websocket) { + return Bun.serve({ fetch: app.fetch, hostname: opts.hostname, idleTimeout: 0, websocket, port }) + } + return Bun.serve({ fetch: app.fetch, hostname: opts.hostname, idleTimeout: 0, port }) + } catch { + return + } + } + const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port) + if (!server) { + throw new Error(`Failed to start server on port ${opts.port}`) + } + if (!server.port) { + throw new Error(`Failed to resolve server address for port ${opts.port}`) + } + return { + port: server.port, + stop(close?: boolean) { + return Promise.resolve(server.stop(close)) + }, + } +} export const adapter: Adapter = { create(app: Hono) { const ws = createBunWebSocket() return { upgradeWebSocket: ws.upgradeWebSocket, - async listen(opts) { - const args = { - fetch: app.fetch, - hostname: opts.hostname, - idleTimeout: 0, - websocket: ws.websocket, - } as const - const start = (port: number) => { - try { - return Bun.serve({ ...args, port }) - } catch { - return - } - } - const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port) - if (!server) { - throw new Error(`Failed to start server on port ${opts.port}`) - } - if (!server.port) { - throw new Error(`Failed to resolve server address for port ${opts.port}`) - } - return { - port: server.port, - stop(close?: boolean) { - return Promise.resolve(server.stop(close)) - }, - } - }, + listen: (opts) => Promise.resolve(listen(app, opts, ws.websocket)), + } + }, + createFetch(app) { + return { + listen: (opts) => Promise.resolve(listen(app, opts)), } }, } diff --git a/packages/opencode/src/server/adapter.node.ts b/packages/opencode/src/server/adapter.node.ts index 9c2a41cce262..2f6b2787f50a 100644 --- a/packages/opencode/src/server/adapter.node.ts +++ b/packages/opencode/src/server/adapter.node.ts @@ -1,66 +1,73 @@ import { createAdaptorServer, type ServerType } from "@hono/node-server" import { createNodeWebSocket } from "@hono/node-ws" import type { Hono } from "hono" -import type { Adapter } from "./adapter" +import type { Adapter, FetchApp, Opts } from "./adapter" + +async function listen(app: FetchApp, opts: Opts, inject?: (server: ServerType) => void) { + const start = (port: number) => + new Promise((resolve, reject) => { + const server = createAdaptorServer({ fetch: app.fetch }) + inject?.(server) + const fail = (err: Error) => { + cleanup() + reject(err) + } + const ready = () => { + cleanup() + resolve(server) + } + const cleanup = () => { + server.off("error", fail) + server.off("listening", ready) + } + server.once("error", fail) + server.once("listening", ready) + server.listen(port, opts.hostname) + }) + + const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port) + const addr = server.address() + if (!addr || typeof addr === "string") { + throw new Error(`Failed to resolve server address for port ${opts.port}`) + } + + let closing: Promise | undefined + return { + port: addr.port, + stop(close?: boolean) { + closing ??= new Promise((resolve, reject) => { + server.close((err) => { + if (err) { + reject(err) + return + } + resolve() + }) + if (close) { + if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") { + server.closeAllConnections() + } + if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") { + server.closeIdleConnections() + } + } + }) + return closing + }, + } +} export const adapter: Adapter = { create(app: Hono) { const ws = createNodeWebSocket({ app }) return { upgradeWebSocket: ws.upgradeWebSocket, - async listen(opts) { - const start = (port: number) => - new Promise((resolve, reject) => { - const server = createAdaptorServer({ fetch: app.fetch }) - ws.injectWebSocket(server) - const fail = (err: Error) => { - cleanup() - reject(err) - } - const ready = () => { - cleanup() - resolve(server) - } - const cleanup = () => { - server.off("error", fail) - server.off("listening", ready) - } - server.once("error", fail) - server.once("listening", ready) - server.listen(port, opts.hostname) - }) - - const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port) - const addr = server.address() - if (!addr || typeof addr === "string") { - throw new Error(`Failed to resolve server address for port ${opts.port}`) - } - - let closing: Promise | undefined - return { - port: addr.port, - stop(close?: boolean) { - closing ??= new Promise((resolve, reject) => { - server.close((err) => { - if (err) { - reject(err) - return - } - resolve() - }) - if (close) { - if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") { - server.closeAllConnections() - } - if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") { - server.closeIdleConnections() - } - } - }) - return closing - }, - } - }, + listen: (opts) => listen(app, opts, ws.injectWebSocket), + } + }, + createFetch(app) { + return { + listen: (opts) => listen(app, opts), } }, } diff --git a/packages/opencode/src/server/adapter.ts b/packages/opencode/src/server/adapter.ts index 272521d7d313..7f4edd2c17a1 100644 --- a/packages/opencode/src/server/adapter.ts +++ b/packages/opencode/src/server/adapter.ts @@ -1,6 +1,10 @@ import type { Hono } from "hono" import type { UpgradeWebSocket } from "hono/ws" +export type FetchApp = { + fetch(request: Request): Response | Promise +} + export type Opts = { port: number hostname: string @@ -18,4 +22,5 @@ export interface Runtime { export interface Adapter { create(app: Hono): Runtime + createFetch(app: FetchApp): Omit } diff --git a/packages/opencode/src/server/routes/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts index 9c50abd62871..7e09fb9ad322 100644 --- a/packages/opencode/src/server/routes/instance/experimental.ts +++ b/packages/opencode/src/server/routes/instance/experimental.ts @@ -37,6 +37,16 @@ const ConsoleSwitchBody = z.object({ orgID: z.string(), }) +const QueryBoolean = z.union([ + z.preprocess((value) => (value === "true" ? true : value === "false" ? false : value), z.boolean()), + z.enum(["true", "false"]), +]) + +function queryBoolean(value: z.infer | undefined) { + if (value === undefined) return + return value === true || value === "true" +} + export const ExperimentalRoutes = lazy(() => new Hono() .get( @@ -346,7 +356,7 @@ export const ExperimentalRoutes = lazy(() => "query", z.object({ directory: z.string().optional().meta({ description: "Filter sessions by project directory" }), - roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }), + roots: QueryBoolean.optional().meta({ description: "Only return root sessions (no parentID)" }), start: z.coerce .number() .optional() @@ -357,7 +367,7 @@ export const ExperimentalRoutes = lazy(() => .meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }), search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }), limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }), - archived: z.coerce.boolean().optional().meta({ description: "Include archived sessions (default false)" }), + archived: QueryBoolean.optional().meta({ description: "Include archived sessions (default false)" }), }), ), async (c) => { @@ -366,12 +376,12 @@ export const ExperimentalRoutes = lazy(() => const sessions: Session.GlobalInfo[] = [] for await (const session of Session.listGlobal({ directory: query.directory, - roots: query.roots, + roots: queryBoolean(query.roots), start: query.start, cursor: query.cursor, search: query.search, limit: limit + 1, - archived: query.archived, + archived: queryBoolean(query.archived), })) { sessions.push(session) } diff --git a/packages/opencode/src/server/routes/instance/httpapi/config.ts b/packages/opencode/src/server/routes/instance/httpapi/config.ts index e659cf74e02c..eef825967bdd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/config.ts @@ -1,7 +1,7 @@ import { Config } from "@/config/config" import { Provider } from "@/provider/provider" import * as InstanceState from "@/effect/instance-state" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "./auth" import { markInstanceForDisposal } from "./lifecycle" @@ -57,7 +57,7 @@ export const ConfigApi = HttpApi.make("config") }), ) -export const configHandlers = Layer.unwrap( +export const configHandlers = HttpApiBuilder.group(ConfigApi, "config", (handlers) => Effect.gen(function* () { const providerSvc = yield* Provider.Service const configSvc = yield* Config.Service @@ -80,8 +80,6 @@ export const configHandlers = Layer.unwrap( } }) - return HttpApiBuilder.group(ConfigApi, "config", (handlers) => - handlers.handle("get", get).handle("update", update).handle("providers", providers), - ) + return handlers.handle("get", get).handle("update", update).handle("providers", providers) }), -).pipe(Layer.provide(Provider.defaultLayer), Layer.provide(Config.defaultLayer)) +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/control.ts b/packages/opencode/src/server/routes/instance/httpapi/control.ts index f850f76e7e8a..718629db7172 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/control.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/control.ts @@ -1,7 +1,8 @@ import { Auth } from "@/auth" import { ProviderID } from "@/provider/schema" -import { Schema } from "effect" -import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import * as Log from "@opencode-ai/core/util/log" +import { Effect, Schema } from "effect" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" const AuthParams = Schema.Struct({ providerID: ProviderID, @@ -69,3 +70,30 @@ export const ControlApi = HttpApi.make("control").add( ) .annotateMerge(OpenApi.annotations({ title: "control", description: "Control plane routes." })), ) + +export const controlHandlers = HttpApiBuilder.group(ControlApi, "control", (handlers) => + Effect.gen(function* () { + const auth = yield* Auth.Service + + const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: { + params: { providerID: ProviderID } + payload: Auth.Info + }) { + yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie) + return true + }) + + const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderID } }) { + yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie) + return true + }) + + const log = Effect.fn("ControlHttpApi.log")(function* (ctx: { payload: typeof LogInput.Type }) { + const logger = Log.create({ service: ctx.payload.service }) + logger[ctx.payload.level](ctx.payload.message, ctx.payload.extra) + return true + }) + + return handlers.handle("authSet", authSet).handle("authRemove", authRemove).handle("log", log) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/experimental.ts index caf32bcbba1b..cc39c7604ba0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/experimental.ts @@ -10,7 +10,7 @@ import { Session } from "@/session/session" import { ToolRegistry } from "@/tool/registry" import * as EffectZod from "@/util/effect-zod" import { Worktree } from "@/worktree" -import { Effect, Layer, Option, Schema } from "effect" +import { Effect, Option, Schema, SchemaGetter } from "effect" import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "./auth" @@ -51,15 +51,21 @@ const ToolListQuery = Schema.Struct({ model: ModelID, }) +const QueryBoolean = Schema.Literals(["true", "false"]).pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === "true"), + encode: SchemaGetter.transform((value) => (value ? "true" : "false")), + }), +) const WorktreeList = Schema.Array(Schema.String).annotate({ identifier: "WorktreeList" }) const SessionListQuery = Schema.Struct({ directory: Schema.optional(Schema.String), - roots: Schema.optional(Schema.Literals(["true", "false"])), + roots: Schema.optional(QueryBoolean), start: Schema.optional(Schema.NumberFromString), cursor: Schema.optional(Schema.NumberFromString), search: Schema.optional(Schema.String), limit: Schema.optional(Schema.NumberFromString), - archived: Schema.optional(Schema.Literals(["true", "false"])), + archived: Schema.optional(QueryBoolean), }) export const ExperimentalPaths = { @@ -99,6 +105,7 @@ export const ExperimentalApi = HttpApi.make("experimental") HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, { payload: ConsoleSwitchPayload, success: Schema.Boolean, + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "experimental.console.switchOrg", @@ -203,7 +210,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ) -export const experimentalHandlers = Layer.unwrap( +export const experimentalHandlers = HttpApiBuilder.group(ExperimentalApi, "experimental", (handlers) => Effect.gen(function* () { const account = yield* Account.Service const agents = yield* Agent.Service @@ -307,12 +314,12 @@ export const experimentalHandlers = Layer.unwrap( const sessions = Array.from( Session.listGlobal({ directory: ctx.query.directory, - roots: ctx.query.roots === "true" ? true : undefined, + roots: ctx.query.roots, start: ctx.query.start, cursor: ctx.query.cursor, search: ctx.query.search, limit: limit + 1, - archived: ctx.query.archived === "true" ? true : undefined, + archived: ctx.query.archived, }), ) const list = sessions.length > limit ? sessions.slice(0, limit) : sessions @@ -328,27 +335,17 @@ export const experimentalHandlers = Layer.unwrap( return yield* mcp.resources() }) - return HttpApiBuilder.group(ExperimentalApi, "experimental", (handlers) => - handlers - .handle("console", getConsole) - .handle("consoleOrgs", listConsoleOrgs) - .handle("consoleSwitch", switchConsole) - .handle("tool", tool) - .handle("toolIDs", toolIDs) - .handle("worktree", worktree) - .handle("worktreeCreate", worktreeCreate) - .handle("worktreeRemove", worktreeRemove) - .handle("worktreeReset", worktreeReset) - .handle("session", session) - .handle("resource", resource), - ) + return handlers + .handle("console", getConsole) + .handle("consoleOrgs", listConsoleOrgs) + .handle("consoleSwitch", switchConsole) + .handle("tool", tool) + .handle("toolIDs", toolIDs) + .handle("worktree", worktree) + .handle("worktreeCreate", worktreeCreate) + .handle("worktreeRemove", worktreeRemove) + .handle("worktreeReset", worktreeReset) + .handle("session", session) + .handle("resource", resource) }), -).pipe( - Layer.provide(Account.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(MCP.defaultLayer), - Layer.provide(Project.defaultLayer), - Layer.provide(ToolRegistry.defaultLayer), - Layer.provide(Worktree.defaultLayer), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/file.ts b/packages/opencode/src/server/routes/instance/httpapi/file.ts index 9f2ab8a3cec3..df525680ae28 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/file.ts @@ -2,7 +2,7 @@ import { File } from "@/file" import { Ripgrep } from "@/file/ripgrep" import * as InstanceState from "@/effect/instance-state" import { LSP } from "@/lsp/lsp" -import { Effect, Layer, Schema } from "effect" +import { Effect, Schema } from "effect" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "./auth" @@ -116,7 +116,7 @@ export const FileApi = HttpApi.make("file") }), ) -export const fileHandlers = Layer.unwrap( +export const fileHandlers = HttpApiBuilder.group(FileApi, "file", (handlers) => Effect.gen(function* () { const svc = yield* File.Service const ripgrep = yield* Ripgrep.Service @@ -154,14 +154,12 @@ export const fileHandlers = Layer.unwrap( return yield* svc.status() }) - return HttpApiBuilder.group(FileApi, "file", (handlers) => - handlers - .handle("findText", findText) - .handle("findFile", findFile) - .handle("findSymbol", findSymbol) - .handle("list", list) - .handle("content", content) - .handle("status", status), - ) + return handlers + .handle("findText", findText) + .handle("findFile", findFile) + .handle("findSymbol", findSymbol) + .handle("list", list) + .handle("content", content) + .handle("status", status) }), -).pipe(Layer.provide(File.defaultLayer), Layer.provide(Ripgrep.defaultLayer)) +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/global.ts b/packages/opencode/src/server/routes/instance/httpapi/global.ts index 215c19ef713d..ef7fb331f6cb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/global.ts @@ -1,13 +1,21 @@ import { Config } from "@/config/config" -import { Schema } from "effect" -import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global" +import { Installation } from "@/installation" +import { Instance } from "@/project/instance" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import * as Log from "@opencode-ai/core/util/log" +import { Effect, Schema } from "effect" +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +const log = Log.create({ service: "server" }) const GlobalHealth = Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String, }).annotate({ identifier: "GlobalHealth" }) -const GlobalEvent = Schema.Struct({ +const GlobalEventSchema = Schema.Struct({ directory: Schema.String, project: Schema.optional(Schema.String), workspace: Schema.optional(Schema.String), @@ -50,7 +58,7 @@ export const GlobalApi = HttpApi.make("global").add( }), ), HttpApiEndpoint.get("event", GlobalPaths.event, { - success: GlobalEvent, + success: GlobalEventSchema, }).annotateMerge( OpenApi.annotations({ identifier: "global.event", @@ -99,3 +107,153 @@ export const GlobalApi = HttpApi.make("global").add( ) .annotateMerge(OpenApi.annotations({ title: "global", description: "Global server routes." })), ) + +function eventData(data: unknown) { + return `data: ${JSON.stringify(data)}\n\n` +} + +function parseBody(body: string) { + try { + return JSON.parse(body || "{}") as unknown + } catch { + return undefined + } +} + +function eventResponse() { + const encoder = new TextEncoder() + let heartbeat: ReturnType | undefined + let unsubscribe = () => {} + let done = false + + const cleanup = () => { + if (done) return + done = true + if (heartbeat) clearInterval(heartbeat) + unsubscribe() + log.info("global event disconnected") + } + + log.info("global event connected") + return HttpServerResponse.raw( + new Response( + new ReadableStream({ + start(controller) { + const write = (data: unknown) => { + if (done) return + try { + controller.enqueue(encoder.encode(eventData(data))) + } catch { + cleanup() + } + } + const handler = (event: GlobalBusEvent) => write(event) + unsubscribe = () => GlobalBus.off("event", handler) + GlobalBus.on("event", handler) + write({ payload: { type: "server.connected", properties: {} } }) + heartbeat = setInterval(() => write({ payload: { type: "server.heartbeat", properties: {} } }), 10_000) + }, + cancel: cleanup, + }), + { + headers: { + "Cache-Control": "no-cache, no-transform", + "Content-Type": "text/event-stream", + "X-Accel-Buffering": "no", + "X-Content-Type-Options": "nosniff", + }, + }, + ), + ) +} + +export const globalHandlers = HttpApiBuilder.group(GlobalApi, "global", (handlers) => + Effect.gen(function* () { + const config = yield* Config.Service + const installation = yield* Installation.Service + + const health = Effect.fn("GlobalHttpApi.health")(function* () { + return { healthy: true as const, version: InstallationVersion } + }) + + const event = Effect.fn("GlobalHttpApi.event")(function* () { + return eventResponse() + }) + + const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () { + return yield* config.getGlobal() + }) + + const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) { + return yield* config.updateGlobal(ctx.payload) + }) + + const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () { + yield* Effect.promise(() => Instance.disposeAll()) + GlobalBus.emit("event", { + directory: "global", + payload: { type: "global.disposed", properties: {} }, + }) + return true + }) + + const upgrade = Effect.fn("GlobalHttpApi.upgrade")(function* (ctx: { payload: typeof GlobalUpgradeInput.Type }) { + const method = yield* installation.method() + if (method === "unknown") { + return { + status: 400, + body: { success: false as const, error: "Unknown installation method" }, + } + } + const target = ctx.payload.target || (yield* installation.latest(method)) + const result = yield* installation.upgrade(method, target).pipe( + Effect.as({ status: 200, body: { success: true as const, version: target } }), + Effect.catch((err) => + Effect.succeed({ + status: 500, + body: { + success: false as const, + error: err instanceof Error ? err.message : String(err), + }, + }), + ), + ) + if (!result.body.success) return result + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Installation.Event.Updated.type, + properties: { version: target }, + }, + }) + return result + }) + + const upgradeRaw = Effect.fn("GlobalHttpApi.upgradeRaw")(function* (ctx: { + request: HttpServerRequest.HttpServerRequest + }) { + const body = yield* Effect.orDie(ctx.request.text) + const json = parseBody(body) + if (json === undefined) { + return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 }) + } + const payload = yield* Schema.decodeUnknownEffect(GlobalUpgradeInput)(json).pipe( + Effect.map((payload) => ({ valid: true as const, payload })), + Effect.catch(() => Effect.succeed({ valid: false as const })), + ) + if (!payload.valid) { + return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 }) + } + const result = yield* upgrade({ payload: payload.payload }) + return HttpServerResponse.jsonUnsafe(result.body, { status: result.status }) + }) + + return handlers + .handle("health", health) + .handleRaw("event", event) + .handle("configGet", configGet) + .handle("configUpdate", configUpdate) + .handle("dispose", dispose) + .handleRaw("upgrade", upgradeRaw) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/instance.ts index d36c43c7671f..8c471c12a0dd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/instance.ts @@ -6,7 +6,7 @@ import { LSP } from "@/lsp/lsp" import { Vcs } from "@/project/vcs" import { Skill } from "@/skill" import * as InstanceState from "@/effect/instance-state" -import { Effect, Layer, Schema } from "effect" +import { Effect, Schema } from "effect" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "./auth" import { markInstanceForDisposal } from "./lifecycle" @@ -140,7 +140,7 @@ export const InstanceApi = HttpApi.make("instance") }), ) -export const instanceHandlers = Layer.unwrap( +export const instanceHandlers = HttpApiBuilder.group(InstanceApi, "instance", (handlers) => Effect.gen(function* () { const agent = yield* Agent.Service const command = yield* Command.Service @@ -194,24 +194,15 @@ export const instanceHandlers = Layer.unwrap( return yield* format.status() }) - return HttpApiBuilder.group(InstanceApi, "instance", (handlers) => - handlers - .handle("dispose", dispose) - .handle("path", getPath) - .handle("vcs", getVcs) - .handle("vcsDiff", getVcsDiff) - .handle("command", getCommand) - .handle("agent", getAgent) - .handle("skill", getSkill) - .handle("lsp", getLsp) - .handle("formatter", getFormatter), - ) + return handlers + .handle("dispose", dispose) + .handle("path", getPath) + .handle("vcs", getVcs) + .handle("vcsDiff", getVcsDiff) + .handle("command", getCommand) + .handle("agent", getAgent) + .handle("skill", getSkill) + .handle("lsp", getLsp) + .handle("formatter", getFormatter) }), -).pipe( - Layer.provide(Agent.defaultLayer), - Layer.provide(Command.defaultLayer), - Layer.provide(Format.defaultLayer), - Layer.provide(LSP.defaultLayer), - Layer.provide(Skill.defaultLayer), - Layer.provide(Vcs.defaultLayer), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/mcp.ts index 692187d73521..f5552f6f2f08 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/mcp.ts @@ -1,6 +1,6 @@ import { MCP } from "@/mcp" import { ConfigMCP } from "@/config/mcp" -import { Effect, Layer, Schema } from "effect" +import { Effect, Schema } from "effect" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "./auth" @@ -20,6 +20,10 @@ const AuthCallbackPayload = Schema.Struct({ const AuthRemoveResponse = Schema.Struct({ success: Schema.Literal(true), }).annotate({ identifier: "McpAuthRemoveResponse" }) +class UnsupportedOAuthError extends Schema.ErrorClass("McpUnsupportedOAuthError")( + { error: Schema.String }, + { httpApiStatus: 400 }, +) {} export const McpPaths = { status: "/mcp", @@ -46,6 +50,7 @@ export const McpApi = HttpApi.make("mcp") HttpApiEndpoint.post("add", McpPaths.status, { payload: AddPayload, success: StatusMap, + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "mcp.add", @@ -56,6 +61,7 @@ export const McpApi = HttpApi.make("mcp") HttpApiEndpoint.post("authStart", McpPaths.auth, { params: { name: Schema.String }, success: AuthStartResponse, + error: UnsupportedOAuthError, }).annotateMerge( OpenApi.annotations({ identifier: "mcp.auth.start", @@ -78,6 +84,7 @@ export const McpApi = HttpApi.make("mcp") HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, { params: { name: Schema.String }, success: MCP.Status, + error: UnsupportedOAuthError, }).annotateMerge( OpenApi.annotations({ identifier: "mcp.auth.authenticate", @@ -130,7 +137,7 @@ export const McpApi = HttpApi.make("mcp") }), ) -export const mcpHandlers = Layer.unwrap( +export const mcpHandlers = HttpApiBuilder.group(McpApi, "mcp", (handlers) => Effect.gen(function* () { const mcp = yield* MCP.Service @@ -146,7 +153,9 @@ export const mcpHandlers = Layer.unwrap( }) const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) { - if (!(yield* mcp.supportsOAuth(ctx.params.name))) return yield* new HttpApiError.BadRequest({}) + if (!(yield* mcp.supportsOAuth(ctx.params.name))) { + return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` }) + } return yield* mcp.startAuth(ctx.params.name) }) @@ -158,7 +167,9 @@ export const mcpHandlers = Layer.unwrap( }) const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) { - if (!(yield* mcp.supportsOAuth(ctx.params.name))) return yield* new HttpApiError.BadRequest({}) + if (!(yield* mcp.supportsOAuth(ctx.params.name))) { + return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` }) + } return yield* mcp.authenticate(ctx.params.name) }) @@ -177,16 +188,14 @@ export const mcpHandlers = Layer.unwrap( return true }) - return HttpApiBuilder.group(McpApi, "mcp", (handlers) => - handlers - .handle("status", status) - .handle("add", add) - .handle("authStart", authStart) - .handle("authCallback", authCallback) - .handle("authAuthenticate", authAuthenticate) - .handle("authRemove", authRemove) - .handle("connect", connect) - .handle("disconnect", disconnect), - ) + return handlers + .handle("status", status) + .handle("add", add) + .handle("authStart", authStart) + .handle("authCallback", authCallback) + .handle("authAuthenticate", authAuthenticate) + .handle("authRemove", authRemove) + .handle("connect", connect) + .handle("disconnect", disconnect) }), -).pipe(Layer.provide(MCP.defaultLayer)) +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/permission.ts index 85dbecd11615..357c832990ae 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/permission.ts @@ -1,6 +1,6 @@ import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" -import { Effect, Layer, Schema } from "effect" +import { Effect, Schema } from "effect" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "./auth" @@ -47,7 +47,7 @@ export const PermissionApi = HttpApi.make("permission") }), ) -export const permissionHandlers = Layer.unwrap( +export const permissionHandlers = HttpApiBuilder.group(PermissionApi, "permission", (handlers) => Effect.gen(function* () { const svc = yield* Permission.Service @@ -67,8 +67,6 @@ export const permissionHandlers = Layer.unwrap( return true }) - return HttpApiBuilder.group(PermissionApi, "permission", (handlers) => - handlers.handle("list", list).handle("reply", reply), - ) + return handlers.handle("list", list).handle("reply", reply) }), -).pipe(Layer.provide(Permission.defaultLayer)) +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/project.ts b/packages/opencode/src/server/routes/instance/httpapi/project.ts index f5a39e39e991..276798b0b946 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/project.ts @@ -3,7 +3,7 @@ import { AppRuntime } from "@/effect/app-runtime" import { Project } from "@/project/project" import { InstanceBootstrap } from "@/project/bootstrap" import { ProjectID } from "@/project/schema" -import { Effect, Layer, Schema } from "effect" +import { Effect, Schema } from "effect" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "./auth" import { markInstanceForReload } from "./lifecycle" @@ -69,7 +69,7 @@ export const ProjectApi = HttpApi.make("project") }), ) -export const projectHandlers = Layer.unwrap( +export const projectHandlers = HttpApiBuilder.group(ProjectApi, "project", (handlers) => Effect.gen(function* () { const svc = yield* Project.Service @@ -102,8 +102,6 @@ export const projectHandlers = Layer.unwrap( return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID }) }) - return HttpApiBuilder.group(ProjectApi, "project", (handlers) => - handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update), - ) + return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update) }), -).pipe(Layer.provide(Project.defaultLayer)) +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/provider.ts index 9f4be61ad692..7dbc491e130c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/provider.ts @@ -4,7 +4,8 @@ import { ModelsDev } from "@/provider/models" import { Provider } from "@/provider/provider" import { ProviderID } from "@/provider/schema" import { mapValues } from "remeda" -import { Effect, Layer, Schema } from "effect" +import { Effect, Schema } from "effect" +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "./auth" @@ -35,7 +36,8 @@ export const ProviderApi = HttpApi.make("provider") HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, { params: { providerID: ProviderID }, payload: ProviderAuth.AuthorizeInput, - success: ProviderAuth.Authorization, + success: Schema.UndefinedOr(ProviderAuth.Authorization), + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "provider.oauth.authorize", @@ -47,6 +49,7 @@ export const ProviderApi = HttpApi.make("provider") params: { providerID: ProviderID }, payload: ProviderAuth.CallbackInput, success: Schema.Boolean, + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "provider.oauth.callback", @@ -71,7 +74,7 @@ export const ProviderApi = HttpApi.make("provider") }), ) -export const providerHandlers = Layer.unwrap( +export const providerHandlers = HttpApiBuilder.group(ProviderApi, "provider", (handlers) => Effect.gen(function* () { const cfg = yield* Config.Service const provider = yield* Provider.Service @@ -115,10 +118,22 @@ export const providerHandlers = Layer.unwrap( inputs: ctx.payload.inputs, }) .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) - if (!result) return yield* new HttpApiError.BadRequest({}) return result }) + const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: { + params: { providerID: ProviderID } + request: HttpServerRequest.HttpServerRequest + }) { + const body = yield* Effect.orDie(ctx.request.text) + const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe( + Effect.mapError(() => new HttpApiError.BadRequest({})), + ) + const result = yield* authorize({ params: ctx.params, payload }) + if (result === undefined) return HttpServerResponse.empty({ status: 200 }) + return HttpServerResponse.jsonUnsafe(result) + }) + const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: { params: { providerID: ProviderID } payload: ProviderAuth.CallbackInput @@ -133,12 +148,10 @@ export const providerHandlers = Layer.unwrap( return true }) - return HttpApiBuilder.group(ProviderApi, "provider", (handlers) => - handlers.handle("list", list).handle("auth", auth).handle("authorize", authorize).handle("callback", callback), - ) + return handlers + .handle("list", list) + .handle("auth", auth) + .handleRaw("authorize", authorizeRaw) + .handle("callback", callback) }), -).pipe( - Layer.provide(ProviderAuth.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Config.defaultLayer), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/pty.ts index f1ac093998a7..d4e77c9d032f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/pty.ts @@ -2,7 +2,7 @@ import { EffectBridge } from "@/effect/bridge" import { Pty } from "@/pty" import { PtyID } from "@/pty/schema" import { Shell } from "@/shell/shell" -import { Effect, Layer, Schema } from "effect" +import { Effect, Schema } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" @@ -118,7 +118,6 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add( .add( HttpApiEndpoint.get("connect", PtyPaths.connect, { params: Params, - query: CursorQuery, success: Schema.Boolean, }).annotateMerge( OpenApi.annotations({ @@ -132,7 +131,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add( .annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })), ) -export const ptyHandlers = Layer.unwrap( +export const ptyHandlers = HttpApiBuilder.group(PtyApi, "pty", (handlers) => Effect.gen(function* () { const pty = yield* Pty.Service @@ -180,15 +179,13 @@ export const ptyHandlers = Layer.unwrap( return true }) - return HttpApiBuilder.group(PtyApi, "pty", (handlers) => - handlers - .handle("shells", shells) - .handle("list", list) - .handle("create", create) - .handle("get", get) - .handle("update", update) - .handle("remove", remove), - ) + return handlers + .handle("shells", shells) + .handle("list", list) + .handle("create", create) + .handle("get", get) + .handle("update", update) + .handle("remove", remove) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 1a7f675b3f94..a4e86e9a5f22 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -17,6 +17,197 @@ import { SyncApi } from "./sync" import { TuiApi } from "./tui" import { WorkspaceApi } from "./workspace" +type OpenApiParameter = { + name: string + in: string + required?: boolean + schema?: OpenApiSchema +} + +type OpenApiOperation = { + parameters?: OpenApiParameter[] + responses?: Record + requestBody?: { + required?: boolean + content?: Record + } +} + +type OpenApiPathItem = Partial> + +type OpenApiSpec = { + components?: { + schemas?: Record + } + paths?: Record +} + +type OpenApiSchema = { + $ref?: string + additionalProperties?: OpenApiSchema | boolean + allOf?: OpenApiSchema[] + anyOf?: OpenApiSchema[] + enum?: string[] + items?: OpenApiSchema + maximum?: number + minimum?: number + oneOf?: OpenApiSchema[] + prefixItems?: OpenApiSchema[] + properties?: Record + type?: string +} + +const InstanceQueryParameters = [ + { + name: "directory", + in: "query", + required: false, + schema: { type: "string" }, + }, + { + name: "workspace", + in: "query", + required: false, + schema: { type: "string" }, + }, +] satisfies OpenApiParameter[] + +const LegacyBodyRefParameters = new Set(["Auth", "Config", "Part", "WorktreeRemoveInput", "WorktreeResetInput"]) +const FiniteNumberValues = new Set(["Infinity", "-Infinity", "NaN"]) +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}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, +} satisfies Record + +function matchLegacyOpenApi(input: Record) { + const spec = input as OpenApiSpec + for (const [path, item] of Object.entries(spec.paths ?? {})) { + const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/") + for (const method of ["get", "post", "put", "delete", "patch"] as const) { + const operation = item[method] + if (!operation) continue + if (operation.requestBody) { + delete operation.requestBody.required + for (const media of Object.values(operation.requestBody.content ?? {})) { + const ref = media.schema?.$ref?.replace("#/components/schemas/", "") + if (ref && LegacyBodyRefParameters.has(ref)) continue + if (ref && spec.components?.schemas?.[ref]) { + media.schema = normalizeRequestSchema(structuredClone(spec.components.schemas[ref])) + continue + } + if (media.schema) media.schema = normalizeRequestSchema(media.schema) + } + if (path === "/experimental/workspace" && method === "post") { + const properties = operation.requestBody.content?.["application/json"]?.schema?.properties + if (properties?.branch) properties.branch = { anyOf: [properties.branch, { type: "null" }] } + if (properties?.extra) properties.extra = { anyOf: [properties.extra, { type: "null" }] } + } + if (path === "/tui/publish" && method === "post" && spec.components?.schemas) { + const schema = operation.requestBody.content?.["application/json"]?.schema + const anyOf = schema?.anyOf + if (anyOf?.length === 4) { + spec.components.schemas.EventTuiPromptAppend = anyOf[0] + spec.components.schemas.EventTuiCommandExecute = anyOf[1] + spec.components.schemas.EventTuiToastShow = anyOf[2] + spec.components.schemas.EventTuiSessionSelect = anyOf[3] + operation.requestBody.content!["application/json"]!.schema = { + anyOf: [ + { $ref: "#/components/schemas/EventTuiPromptAppend" }, + { $ref: "#/components/schemas/EventTuiCommandExecute" }, + { $ref: "#/components/schemas/EventTuiToastShow" }, + { $ref: "#/components/schemas/EventTuiSessionSelect" }, + ], + } + } + } + if (path === "/sync/replay" && method === "post" && spec.components?.schemas?.SyncReplayEvent) { + const events = operation.requestBody.content?.["application/json"]?.schema?.properties?.events + if (events?.items?.$ref === "#/components/schemas/SyncReplayEvent") { + events.items = normalizeRequestSchema(structuredClone(spec.components.schemas.SyncReplayEvent)) + } + } + } + if ((path === "/event" || path === "/global/event") && method === "get") { + operation.responses!["200"] = { + description: "Event stream", + content: { + "text/event-stream": { + schema: path === "/event" ? {} : { $ref: "#/components/schemas/GlobalEvent" }, + }, + }, + } + } + if (!isInstanceRoute) continue + operation.parameters = [ + ...InstanceQueryParameters, + ...(operation.parameters ?? []).filter( + (param) => param.in !== "query" || (param.name !== "directory" && param.name !== "workspace"), + ), + ] + for (const param of operation.parameters) normalizeParameter(param, `${method.toUpperCase()} ${path}`) + } + } + return input +} + +function normalizeRequestSchema(schema: OpenApiSchema): OpenApiSchema { + const options = flattenOptions(schema.anyOf ?? schema.oneOf) + if (options) { + const withoutNull = options.filter((item) => item.type !== "null") + const finite = withoutNull.find((item) => item.type === "number") + if (finite && withoutNull.every(isFiniteNumberOption)) return { type: "number" } + if (withoutNull.length === 1) return normalizeRequestSchema(withoutNull[0]) + if (schema.anyOf) schema.anyOf = withoutNull.map(normalizeRequestSchema) + if (schema.oneOf) schema.oneOf = withoutNull.map(normalizeRequestSchema) + } + if (schema.allOf) { + if (schema.type) delete schema.allOf + else schema.allOf = schema.allOf.map(normalizeRequestSchema) + } + if (schema.prefixItems && schema.items) delete schema.prefixItems + if (schema.items) schema.items = normalizeRequestSchema(schema.items) + if (schema.properties) { + for (const [key, value] of Object.entries(schema.properties)) { + schema.properties[key] = normalizeRequestSchema(value) + } + } + if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + schema.additionalProperties = normalizeRequestSchema(schema.additionalProperties) + } + return schema +} + +function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | undefined { + return options?.flatMap((item) => flattenOptions(item.anyOf ?? item.oneOf) ?? [item]) +} + +function isFiniteNumberOption(schema: OpenApiSchema) { + if (schema.type === "number") return true + return schema.type === "string" && schema.enum?.every((value) => FiniteNumberValues.has(value)) === true +} + +function normalizeParameter(param: OpenApiParameter, route: string) { + 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 (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 = normalizeRequestSchema(param.schema) +} + export const PublicApi = HttpApi.make("opencode") .addHttpApi(ControlApi) .addHttpApi(GlobalApi) @@ -41,5 +232,6 @@ export const PublicApi = HttpApi.make("opencode") title: "opencode", version: "1.0.0", description: "opencode api", + transform: matchLegacyOpenApi, }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/question.ts b/packages/opencode/src/server/routes/instance/httpapi/question.ts index 526a78ee0ac6..2169e17c5cc5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/question.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/question.ts @@ -1,6 +1,6 @@ import { Question } from "@/question" import { QuestionID } from "@/question/schema" -import { Effect, Layer, Schema } from "effect" +import { Effect, Schema } from "effect" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "./auth" @@ -57,7 +57,7 @@ export const QuestionApi = HttpApi.make("question") }), ) -export const questionHandlers = Layer.unwrap( +export const questionHandlers = HttpApiBuilder.group(QuestionApi, "question", (handlers) => Effect.gen(function* () { const svc = yield* Question.Service @@ -81,8 +81,6 @@ export const questionHandlers = Layer.unwrap( return true }) - return HttpApiBuilder.group(QuestionApi, "question", (handlers) => - handlers.handle("list", list).handle("reply", reply).handle("reject", reject), - ) + return handlers.handle("list", list).handle("reply", reply).handle("reject", reject) }), -).pipe(Layer.provide(Question.defaultLayer)) +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 6a719d94bee0..e96c21b55bc7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -1,21 +1,47 @@ -import { Effect, Layer, Schema } from "effect" +import { Context, Effect, Layer, Schema } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http" +import { Account } from "@/account/account" +import { Agent } from "@/agent/agent" +import { Auth } from "@/auth" import { Bus } from "@/bus" +import { Config } from "@/config/config" +import { Command } from "@/command" import { AppRuntime } from "@/effect/app-runtime" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import * as Observability from "@opencode-ai/core/effect/observability" +import { File } from "@/file" +import { Ripgrep } from "@/file/ripgrep" +import { Format } from "@/format" +import { LSP } from "@/lsp/lsp" +import { MCP } from "@/mcp" +import { Permission } from "@/permission" import { InstanceBootstrap } from "@/project/bootstrap" import { Instance } from "@/project/instance" +import { Installation } from "@/installation" +import { Project } from "@/project/project" +import { ProviderAuth } from "@/provider/auth" +import { Provider } from "@/provider/provider" import { Pty } from "@/pty" +import { Question } from "@/question" import { Session } from "@/session/session" +import { SessionRunState } from "@/session/run-state" +import { SessionStatus } from "@/session/status" +import { SessionSummary } from "@/session/summary" +import { Todo } from "@/session/todo" +import { Skill } from "@/skill" +import { ToolRegistry } from "@/tool/registry" import { lazy } from "@/util/lazy" import { Filesystem } from "@/util/filesystem" +import { Vcs } from "@/project/vcs" +import { Worktree } from "@/worktree" import { authorizationLayer } from "./auth" import { ConfigApi, configHandlers } from "./config" +import { ControlApi, controlHandlers } from "./control" import { eventRoute } from "./event" import { FileApi, fileHandlers } from "./file" import { ExperimentalApi, experimentalHandlers } from "./experimental" +import { GlobalApi, globalHandlers } from "./global" import { InstanceApi, instanceHandlers } from "./instance" import { McpApi, mcpHandlers } from "./mcp" import { PermissionApi, permissionHandlers } from "./permission" @@ -41,6 +67,8 @@ const Headers = Schema.Struct({ "x-opencode-directory": Schema.optional(Schema.String), }) +export const context = Context.empty() as Context.Context + function decode(input: string) { try { return decodeURIComponent(input) @@ -71,34 +99,65 @@ const instance = HttpRouter.middleware()( }), ).layer -export const routes = Layer.mergeAll( - eventRoute, - ptyConnectRoute, +const controlRoutes = HttpApiBuilder.layer(ControlApi).pipe(Layer.provide(controlHandlers)) +const globalRoutes = HttpApiBuilder.layer(GlobalApi).pipe(Layer.provide(globalHandlers)) +const instanceApiRoutes = Layer.mergeAll( HttpApiBuilder.layer(ConfigApi).pipe(Layer.provide(configHandlers)), HttpApiBuilder.layer(ExperimentalApi).pipe(Layer.provide(experimentalHandlers)), HttpApiBuilder.layer(FileApi).pipe(Layer.provide(fileHandlers)), HttpApiBuilder.layer(InstanceApi).pipe(Layer.provide(instanceHandlers)), HttpApiBuilder.layer(McpApi).pipe(Layer.provide(mcpHandlers)), HttpApiBuilder.layer(ProjectApi).pipe(Layer.provide(projectHandlers)), - HttpApiBuilder.layer(PtyApi).pipe(Layer.provide(ptyHandlers), Layer.provide(Pty.defaultLayer)), + HttpApiBuilder.layer(PtyApi).pipe(Layer.provide(ptyHandlers)), HttpApiBuilder.layer(QuestionApi).pipe(Layer.provide(questionHandlers)), HttpApiBuilder.layer(PermissionApi).pipe(Layer.provide(permissionHandlers)), HttpApiBuilder.layer(ProviderApi).pipe(Layer.provide(providerHandlers)), HttpApiBuilder.layer(SessionApi).pipe(Layer.provide(sessionHandlers)), HttpApiBuilder.layer(SyncApi).pipe(Layer.provide(syncHandlers)), - HttpApiBuilder.layer(TuiApi).pipe( - Layer.provide(tuiHandlers), - Layer.provide(Session.defaultLayer), - Layer.provide(Bus.layer), - ), + HttpApiBuilder.layer(TuiApi).pipe(Layer.provide(tuiHandlers)), HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)), -).pipe( +) + +const instanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute, instanceApiRoutes).pipe( Layer.provide(authorizationLayer), Layer.provide(instance), - Layer.provide(HttpServer.layerServices), - Layer.provideMerge(Observability.layer), ) +export const routes = Layer.mergeAll(controlRoutes, globalRoutes, instanceRoutes) + .pipe( + Layer.provide(Account.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(Auth.defaultLayer), + Layer.provide(Command.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(File.defaultLayer), + Layer.provide(Format.defaultLayer), + Layer.provide(LSP.defaultLayer), + Layer.provide(Installation.defaultLayer), + Layer.provide(MCP.defaultLayer), + Layer.provide(Permission.defaultLayer), + Layer.provide(Project.defaultLayer), + Layer.provide(ProviderAuth.defaultLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(Pty.defaultLayer), + Layer.provide(Question.defaultLayer), + Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Session.defaultLayer), + ) + .pipe( + Layer.provide(SessionRunState.defaultLayer), + Layer.provide(SessionStatus.defaultLayer), + Layer.provide(SessionSummary.defaultLayer), + Layer.provide(Skill.defaultLayer), + Layer.provide(Todo.defaultLayer), + Layer.provide(ToolRegistry.defaultLayer), + Layer.provide(Vcs.defaultLayer), + Layer.provide(Worktree.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(HttpServer.layerServices), + Layer.provideMerge(Observability.layer), + ) + export const webHandler = lazy(() => HttpRouter.toWebHandler(routes, { memoMap, diff --git a/packages/opencode/src/server/routes/instance/httpapi/session.ts b/packages/opencode/src/server/routes/instance/httpapi/session.ts index dccfb3ecbdf1..6ea19f19e473 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/session.ts @@ -21,7 +21,7 @@ import { MessageID, PartID, SessionID } from "@/session/schema" import { Snapshot } from "@/snapshot" import * as Log from "@opencode-ai/core/util/log" import { NamedError } from "@opencode-ai/core/util/error" -import { Effect, Layer, Schema, Struct } from "effect" +import { Effect, Schema, SchemaGetter, Struct } from "effect" import * as Stream from "effect/Stream" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { @@ -37,9 +37,17 @@ import { Authorization } from "./auth" const log = Log.create({ service: "server" }) const root = "/session" +const QueryBoolean = Schema.Literals(["true", "false"]).pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === "true"), + encode: SchemaGetter.transform((value) => (value ? "true" : "false")), + }), +) const ListQuery = Schema.Struct({ directory: Schema.optional(Schema.String), - roots: Schema.optional(Schema.Literals(["true", "false"])), + scope: Schema.optional(Schema.Literals(["project"])), + path: Schema.optional(Schema.String), + roots: Schema.optional(QueryBoolean), start: Schema.optional(Schema.NumberFromString), search: Schema.optional(Schema.String), limit: Schema.optional(Schema.NumberFromString), @@ -185,6 +193,7 @@ export const SessionApi = HttpApi.make("session") params: { sessionID: SessionID }, query: MessagesQuery, success: Schema.Array(MessageV2.WithParts), + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "session.messages", @@ -205,6 +214,7 @@ export const SessionApi = HttpApi.make("session") HttpApiEndpoint.post("create", SessionPaths.create, { payload: [HttpApiSchema.NoContent, Session.CreateInput], success: Session.Info, + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "session.create", @@ -423,7 +433,7 @@ export const SessionApi = HttpApi.make("session") }), ) -export const sessionHandlers = Layer.unwrap( +export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (handlers) => Effect.gen(function* () { const session = yield* Session.Service const statusSvc = yield* SessionStatus.Service @@ -436,7 +446,9 @@ export const sessionHandlers = Layer.unwrap( Array.from( Session.list({ directory: ctx.query.directory, - roots: ctx.query.roots === "true" ? true : undefined, + scope: ctx.query.scope, + path: ctx.query.path, + roots: ctx.query.roots, start: ctx.query.start, search: ctx.query.search, limit: ctx.query.limit, @@ -472,8 +484,8 @@ export const sessionHandlers = Layer.unwrap( params: { sessionID: SessionID } query: typeof MessagesQuery.Type }) { - if (ctx.query.before !== undefined && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({}) - if (ctx.query.before !== undefined) { + if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({}) + if (ctx.query.before) { const before = ctx.query.before yield* Effect.try({ try: () => MessageV2.cursor.decode(before), @@ -900,41 +912,33 @@ export const sessionHandlers = Layer.unwrap( ) }) - return HttpApiBuilder.group(SessionApi, "session", (handlers) => - handlers - .handle("list", list) - .handle("status", status) - .handle("get", get) - .handle("children", children) - .handle("todo", todo) - .handle("diff", diff) - .handle("messages", messages) - .handle("message", message) - .handleRaw("create", createRaw) - .handle("remove", remove) - .handle("update", update) - .handle("fork", fork) - .handle("abort", abort) - .handle("init", init) - .handle("share", share) - .handle("unshare", unshare) - .handle("summarize", summarize) - .handle("prompt", prompt) - .handle("promptAsync", promptAsync) - .handle("command", command) - .handle("shell", shell) - .handle("revert", revert) - .handle("unrevert", unrevert) - .handle("permissionRespond", permissionRespond) - .handle("deleteMessage", deleteMessage) - .handle("deletePart", deletePart) - .handle("updatePart", updatePart), - ) + return handlers + .handle("list", list) + .handle("status", status) + .handle("get", get) + .handle("children", children) + .handle("todo", todo) + .handle("diff", diff) + .handle("messages", messages) + .handle("message", message) + .handleRaw("create", createRaw) + .handle("remove", remove) + .handle("update", update) + .handle("fork", fork) + .handle("abort", abort) + .handle("init", init) + .handle("share", share) + .handle("unshare", unshare) + .handle("summarize", summarize) + .handle("prompt", prompt) + .handle("promptAsync", promptAsync) + .handle("command", command) + .handle("shell", shell) + .handle("revert", revert) + .handle("unrevert", unrevert) + .handle("permissionRespond", permissionRespond) + .handle("deleteMessage", deleteMessage) + .handle("deletePart", deletePart) + .handle("updatePart", updatePart) }), -).pipe( - Layer.provide(Session.defaultLayer), - Layer.provide(SessionRunState.defaultLayer), - Layer.provide(SessionStatus.defaultLayer), - Layer.provide(Todo.defaultLayer), - Layer.provide(SessionSummary.defaultLayer), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/sync.ts index 8e19cdccde52..67fcede2f8cd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/sync.ts @@ -9,15 +9,16 @@ import { not } from "drizzle-orm" import { or } from "drizzle-orm" import { SyncEvent } from "@/sync" import { EventTable } from "@/sync/event.sql" -import { Effect, Layer, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { NonNegativeInt } from "@/util/schema" +import { Effect, Schema } from "effect" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "./auth" const root = "/sync" const ReplayEvent = Schema.Struct({ id: Schema.String, aggregateID: Schema.String, - seq: Schema.Number, + seq: NonNegativeInt, type: Schema.String, data: Schema.Record(Schema.String, Schema.Unknown), }).annotate({ identifier: "SyncReplayEvent" }) @@ -28,7 +29,7 @@ const ReplayPayload = Schema.Struct({ const ReplayResponse = Schema.Struct({ sessionID: Schema.String, }).annotate({ identifier: "SyncReplayResponse" }) -const HistoryPayload = Schema.Record(Schema.String, Schema.Number) +const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt) const HistoryEvent = Schema.Struct({ id: Schema.String, aggregate_id: Schema.String, @@ -59,6 +60,7 @@ export const SyncApi = HttpApi.make("sync") HttpApiEndpoint.post("replay", SyncPaths.replay, { payload: ReplayPayload, success: ReplayResponse, + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "sync.replay", @@ -69,6 +71,7 @@ export const SyncApi = HttpApi.make("sync") HttpApiEndpoint.post("history", SyncPaths.history, { payload: HistoryPayload, success: Schema.Array(HistoryEvent), + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "sync.history.list", @@ -94,7 +97,7 @@ export const SyncApi = HttpApi.make("sync") }), ) -export const syncHandlers = Layer.unwrap( +export const syncHandlers = HttpApiBuilder.group(SyncApi, "sync", (handlers) => Effect.gen(function* () { const start = Effect.fn("SyncHttpApi.start")(function* () { startWorkspaceSyncing((yield* InstanceState.context).project.id) @@ -129,8 +132,6 @@ export const syncHandlers = Layer.unwrap( ) }) - return HttpApiBuilder.group(SyncApi, "sync", (handlers) => - handlers.handle("start", start).handle("replay", replay).handle("history", history), - ) + return handlers.handle("start", start).handle("replay", replay).handle("history", history) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/tui.ts index c5695cf077e7..2bcc740ddde6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/tui.ts @@ -4,7 +4,7 @@ import { SessionID } from "@/session/schema" import { SessionTable } from "@/session/session.sql" import * as Database from "@/storage/db" import { eq } from "drizzle-orm" -import { Effect, Layer, Schema } from "effect" +import { Effect, Schema } from "effect" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { nextTuiRequest, submitTuiResponse } from "../tui" import { Authorization } from "./auth" @@ -61,6 +61,7 @@ export const TuiApi = HttpApi.make("tui") HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, { payload: TuiEvent.PromptAppend.properties, success: Schema.Boolean, + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "tui.appendPrompt", @@ -113,6 +114,7 @@ export const TuiApi = HttpApi.make("tui") HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, { payload: CommandPayload, success: Schema.Boolean, + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "tui.executeCommand", @@ -133,6 +135,7 @@ export const TuiApi = HttpApi.make("tui") HttpApiEndpoint.post("publish", TuiPaths.publish, { payload: TuiPublishPayload, success: Schema.Boolean, + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "tui.publish", @@ -143,7 +146,7 @@ export const TuiApi = HttpApi.make("tui") HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, { payload: TuiEvent.SessionSelect.properties, success: Schema.Boolean, - error: HttpApiError.NotFound, + error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( OpenApi.annotations({ identifier: "tui.selectSession", @@ -180,7 +183,7 @@ export const TuiApi = HttpApi.make("tui") }), ) -export const tuiHandlers = Layer.unwrap( +export const tuiHandlers = HttpApiBuilder.group(TuiApi, "tui", (handlers) => Effect.gen(function* () { const bus = yield* Bus.Service const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) => @@ -270,21 +273,19 @@ export const tuiHandlers = Layer.unwrap( return true }) - return HttpApiBuilder.group(TuiApi, "tui", (handlers) => - handlers - .handle("appendPrompt", appendPrompt) - .handle("openHelp", openHelp) - .handle("openSessions", openSessions) - .handle("openThemes", openThemes) - .handle("openModels", openModels) - .handle("submitPrompt", submitPrompt) - .handle("clearPrompt", clearPrompt) - .handle("executeCommand", executeCommand) - .handle("showToast", showToast) - .handle("publish", publish) - .handle("selectSession", selectSession) - .handle("controlNext", controlNext) - .handle("controlResponse", controlResponse), - ) + return handlers + .handle("appendPrompt", appendPrompt) + .handle("openHelp", openHelp) + .handle("openSessions", openSessions) + .handle("openThemes", openThemes) + .handle("openModels", openModels) + .handle("submitPrompt", submitPrompt) + .handle("clearPrompt", clearPrompt) + .handle("executeCommand", executeCommand) + .handle("showToast", showToast) + .handle("publish", publish) + .handle("selectSession", selectSession) + .handle("controlNext", controlNext) + .handle("controlResponse", controlResponse) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/workspace.ts index c26959601121..1c5b4f87d806 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/workspace.ts @@ -3,7 +3,7 @@ import { Workspace } from "@/control-plane/workspace" import { WorkspaceAdaptorEntry } from "@/control-plane/types" import * as InstanceState from "@/effect/instance-state" import { Instance } from "@/project/instance" -import { Effect, Layer, Schema, Struct } from "effect" +import { Effect, Schema, Struct } from "effect" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "./auth" @@ -107,7 +107,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ) -export const workspaceHandlers = Layer.unwrap( +export const workspaceHandlers = HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) => Effect.gen(function* () { const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () { const ctx = yield* InstanceState.context @@ -155,14 +155,12 @@ export const workspaceHandlers = Layer.unwrap( ) }) - return HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) => - handlers - .handle("adaptors", adaptors) - .handle("list", list) - .handle("create", create) - .handle("status", status) - .handle("remove", remove) - .handle("sessionRestore", sessionRestore), - ) + return handlers + .handle("adaptors", adaptors) + .handle("list", list) + .handle("create", create) + .handle("status", status) + .handle("remove", remove) + .handle("sessionRestore", sessionRestore) }), ) diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 68b508a9a750..fa11e3e90d16 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -1,7 +1,7 @@ import { describeRoute, resolver, validator } from "hono-openapi" import { Hono } from "hono" import type { UpgradeWebSocket } from "hono/ws" -import { Context, Effect } from "effect" +import { Effect } from "effect" import z from "zod" import { Format } from "@/format" import { TuiRoutes } from "./tui" @@ -14,18 +14,6 @@ import { LSP } from "@/lsp/lsp" import { Command } from "@/command" import { QuestionRoutes } from "./question" import { PermissionRoutes } from "./permission" -import { Flag } from "@opencode-ai/core/flag/flag" -import { ExperimentalHttpApiServer } from "./httpapi/server" -import { PtyPaths } from "./httpapi/pty" -import { EventPaths } from "./httpapi/event" -import { ExperimentalPaths } from "./httpapi/experimental" -import { FilePaths } from "./httpapi/file" -import { InstancePaths } from "./httpapi/instance" -import { McpPaths } from "./httpapi/mcp" -import { SessionPaths } from "./httpapi/session" -import { SyncPaths } from "./httpapi/sync" -import { TuiPaths } from "./httpapi/tui" -import { WorkspacePaths } from "./httpapi/workspace" import { ProjectRoutes } from "./project" import { SessionRoutes } from "./session" import { PtyRoutes } from "./pty" @@ -42,118 +30,6 @@ import { jsonRequest } from "./trace" export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { const app = new Hono() - if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { - const handler = ExperimentalHttpApiServer.webHandler().handler - const context = Context.empty() as Context.Context - app.get(EventPaths.event, (c) => handler(c.req.raw, context)) - app.get("/question", (c) => handler(c.req.raw, context)) - app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context)) - app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context)) - app.get("/permission", (c) => handler(c.req.raw, context)) - app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context)) - app.get("/config", (c) => handler(c.req.raw, context)) - app.patch("/config", (c) => handler(c.req.raw, context)) - app.get("/config/providers", (c) => handler(c.req.raw, context)) - app.get(ExperimentalPaths.console, (c) => handler(c.req.raw, context)) - app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context)) - app.post(ExperimentalPaths.consoleSwitch, (c) => handler(c.req.raw, context)) - app.get(ExperimentalPaths.tool, (c) => handler(c.req.raw, context)) - app.get(ExperimentalPaths.toolIDs, (c) => handler(c.req.raw, context)) - app.get(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) - app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) - app.delete(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) - app.post(ExperimentalPaths.worktreeReset, (c) => handler(c.req.raw, context)) - app.get(ExperimentalPaths.session, (c) => handler(c.req.raw, context)) - app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context)) - app.get("/provider", (c) => handler(c.req.raw, context)) - app.get("/provider/auth", (c) => handler(c.req.raw, context)) - app.post("/provider/:providerID/oauth/authorize", (c) => handler(c.req.raw, context)) - app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context)) - app.get("/project", (c) => handler(c.req.raw, context)) - app.get("/project/current", (c) => handler(c.req.raw, context)) - app.post("/project/git/init", (c) => handler(c.req.raw, context)) - app.patch("/project/:projectID", (c) => handler(c.req.raw, context)) - app.get(FilePaths.findText, (c) => handler(c.req.raw, context)) - app.get(FilePaths.findFile, (c) => handler(c.req.raw, context)) - app.get(FilePaths.findSymbol, (c) => handler(c.req.raw, context)) - app.get(FilePaths.list, (c) => handler(c.req.raw, context)) - app.get(FilePaths.content, (c) => handler(c.req.raw, context)) - app.get(FilePaths.status, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.path, (c) => handler(c.req.raw, context)) - app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.command, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.agent, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.skill, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.lsp, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.formatter, (c) => handler(c.req.raw, context)) - app.get(McpPaths.status, (c) => handler(c.req.raw, context)) - app.post(McpPaths.status, (c) => handler(c.req.raw, context)) - app.post(McpPaths.auth, (c) => handler(c.req.raw, context)) - app.post(McpPaths.authCallback, (c) => handler(c.req.raw, context)) - app.post(McpPaths.authAuthenticate, (c) => handler(c.req.raw, context)) - app.delete(McpPaths.auth, (c) => handler(c.req.raw, context)) - app.post(McpPaths.connect, (c) => handler(c.req.raw, context)) - app.post(McpPaths.disconnect, (c) => handler(c.req.raw, context)) - app.post(SyncPaths.start, (c) => handler(c.req.raw, context)) - app.post(SyncPaths.replay, (c) => handler(c.req.raw, context)) - app.post(SyncPaths.history, (c) => handler(c.req.raw, context)) - app.get(PtyPaths.shells, (c) => handler(c.req.raw, context)) - app.get(PtyPaths.list, (c) => handler(c.req.raw, context)) - app.post(PtyPaths.create, (c) => handler(c.req.raw, context)) - app.get(PtyPaths.get, (c) => handler(c.req.raw, context)) - app.put(PtyPaths.update, (c) => handler(c.req.raw, context)) - app.delete(PtyPaths.remove, (c) => handler(c.req.raw, context)) - app.get(PtyPaths.connect, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.list, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.status, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.get, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.children, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.todo, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.diff, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.messages, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.message, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.create, (c) => handler(c.req.raw, context)) - app.delete(SessionPaths.remove, (c) => handler(c.req.raw, context)) - app.patch(SessionPaths.update, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.init, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.fork, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.abort, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.share, (c) => handler(c.req.raw, context)) - app.delete(SessionPaths.share, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.summarize, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.prompt, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.promptAsync, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.command, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.shell, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.revert, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.unrevert, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.permissions, (c) => handler(c.req.raw, context)) - app.delete(SessionPaths.deleteMessage, (c) => handler(c.req.raw, context)) - app.delete(SessionPaths.deletePart, (c) => handler(c.req.raw, context)) - app.patch(SessionPaths.updatePart, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.appendPrompt, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.openHelp, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.openSessions, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.openThemes, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.openModels, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.submitPrompt, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.clearPrompt, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.executeCommand, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.showToast, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.publish, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.selectSession, (c) => handler(c.req.raw, context)) - app.get(TuiPaths.controlNext, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.controlResponse, (c) => handler(c.req.raw, context)) - app.get(WorkspacePaths.adaptors, (c) => handler(c.req.raw, context)) - app.post(WorkspacePaths.list, (c) => handler(c.req.raw, context)) - app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context)) - app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context)) - app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context)) - app.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context)) - } - return app .route("/project", ProjectRoutes()) .route("/pty", PtyRoutes(upgrade)) diff --git a/packages/opencode/src/server/routes/instance/mcp.ts b/packages/opencode/src/server/routes/instance/mcp.ts index b47a6d29a961..d5542f042bbb 100644 --- a/packages/opencode/src/server/routes/instance/mcp.ts +++ b/packages/opencode/src/server/routes/instance/mcp.ts @@ -8,6 +8,21 @@ import { lazy } from "@/util/lazy" import { Effect } from "effect" import { jsonRequest, runRequest } from "./trace" +const UnsupportedOAuthError = z + .object({ + error: z.string(), + }) + .meta({ ref: "McpUnsupportedOAuthError" }) + +const unsupportedOAuthErrorResponse = { + description: "MCP server does not support OAuth", + content: { + "application/json": { + schema: resolver(UnsupportedOAuthError), + }, + }, +} + export const McpRoutes = lazy(() => new Hono() .get( @@ -85,7 +100,8 @@ export const McpRoutes = lazy(() => }, }, }, - ...errors(400, 404), + 400: unsupportedOAuthErrorResponse, + ...errors(404), }, }), async (c) => { @@ -157,7 +173,8 @@ export const McpRoutes = lazy(() => }, }, }, - ...errors(400, 404), + 400: unsupportedOAuthErrorResponse, + ...errors(404), }, }), async (c) => { diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts index 5791a0cd767b..410d8bba0c97 100644 --- a/packages/opencode/src/server/routes/instance/session.ts +++ b/packages/opencode/src/server/routes/instance/session.ts @@ -30,6 +30,16 @@ import { jsonRequest, runRequest } from "./trace" const log = Log.create({ service: "server" }) +const QueryBoolean = z.union([ + z.preprocess((value) => (value === "true" ? true : value === "false" ? false : value), z.boolean()), + z.enum(["true", "false"]), +]) + +function queryBoolean(value: z.infer | undefined) { + if (value === undefined) return + return value === true || value === "true" +} + export const SessionRoutes = lazy(() => new Hono() .get( @@ -52,8 +62,12 @@ export const SessionRoutes = lazy(() => validator( "query", z.object({ - directory: z.string().optional().meta({ description: "Filter sessions by project directory" }), - roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }), + directory: z.string().optional().meta({ description: "Filter sessions by directory" }), + // TODO: in 2.0 remove `scope` and `directory` and default + // to list all sessions for a project + scope: z.enum(["project"]).optional().meta({ description: "List all sessions for the current project" }), + path: z.string().optional().meta({ description: "Filter sessions by project-relative path" }), + roots: QueryBoolean.optional().meta({ description: "Only return root sessions (no parentID)" }), start: z.coerce .number() .optional() @@ -66,8 +80,9 @@ export const SessionRoutes = lazy(() => const query = c.req.valid("query") const sessions: Session.Info[] = [] for await (const session of Session.list({ - directory: query.directory, - roots: query.roots, + directory: query.scope === "project" ? undefined : query.directory, + path: query.path, + roots: queryBoolean(query.roots), start: query.start, search: query.search, limit: query.limit, diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 7d5373dd9663..92d844fbfec4 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -17,8 +17,6 @@ import { WorkspaceRouterMiddleware } from "./workspace" import { InstanceMiddleware } from "./routes/instance/middleware" import { WorkspaceRoutes } from "./routes/control/workspace" import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server" -import { WorkspacePaths } from "./routes/instance/httpapi/workspace" -import { Context } from "effect" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -34,9 +32,35 @@ export type Listener = { stop: (close?: boolean) => Promise } -export const Default = lazy(() => create({})) +type ServerApp = { + fetch(request: Request): Response | Promise + request(input: string | URL | Request, init?: RequestInit): Response | Promise +} + +const DefaultHono = lazy(() => createHono({})) +const DefaultHttpApi = lazy(() => createHttpApi()) +export const Default = () => (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI ? DefaultHttpApi() : DefaultHono()) function create(opts: { cors?: string[] }) { + if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) return createHttpApi() + return createHono(opts) +} + +function createHttpApi() { + const handler = ExperimentalHttpApiServer.webHandler().handler + const app: ServerApp = { + fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context), + request(input, init) { + return app.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init)) + }, + } + return { + app, + runtime: adapter.createFetch(app), + } +} + +function createHono(opts: { cors?: string[] }) { const app = new Hono() .onError(ErrorMiddleware) .use(AuthMiddleware) @@ -62,16 +86,6 @@ function create(opts: { cors?: string[] }) { .use(InstanceMiddleware()) .route("/experimental/workspace", WorkspaceRoutes()) .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)) - if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { - const handler = ExperimentalHttpApiServer.webHandler().handler - const context = Context.empty() as Context.Context - workspaceApp.get(WorkspacePaths.adaptors, (c) => handler(c.req.raw, context)) - workspaceApp.get(WorkspacePaths.list, (c) => handler(c.req.raw, context)) - workspaceApp.post(WorkspacePaths.list, (c) => handler(c.req.raw, context)) - workspaceApp.get(WorkspacePaths.status, (c) => handler(c.req.raw, context)) - workspaceApp.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context)) - workspaceApp.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context)) - } workspaceApp.route("/", workspaceLegacyApp) return { @@ -89,7 +103,7 @@ export async function openapi() { // hono-openapi can see describeRoute metadata (`.route()` wraps // handlers when the sub-app has a custom errorHandler, which // strips the metadata symbol). - const { app } = create({}) + const { app } = createHono({}) const result = await generateSpecs(app, { documentation: { info: { diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index 35c84738094e..a3832ebe655c 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -44,6 +44,7 @@ export function toPartialRow(info: DeepPartial) { parent_id: grab(info, "parentID"), slug: grab(info, "slug"), directory: grab(info, "directory"), + path: grab(info, "path"), title: grab(info, "title"), version: grab(info, "version"), share_url: grab(info, "share", (v) => grab(v, "url")), diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 35ed8fdda48a..863fb21d65c7 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -24,6 +24,7 @@ export const SessionTable = sqliteTable( parent_id: text().$type(), slug: text().notNull(), directory: text().notNull(), + path: text(), title: text().notNull(), version: text().notNull(), share_url: text(), diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 673347b206ff..45b8f0078f50 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -18,6 +18,7 @@ import { desc } from "drizzle-orm" import { like } from "drizzle-orm" import { inArray } from "drizzle-orm" import { lt } from "drizzle-orm" +import { or } from "drizzle-orm" import { SyncEvent } from "../sync" import type { SQL } from "drizzle-orm" import { PartTable, SessionTable } from "./session.sql" @@ -74,6 +75,7 @@ export function fromRow(row: SessionRow): Info { projectID: row.project_id, workspaceID: row.workspace_id ?? undefined, directory: row.directory, + path: row.path ?? undefined, parentID: row.parent_id ?? undefined, title: row.title, version: row.version, @@ -98,6 +100,7 @@ export function toRow(info: Info) { parent_id: info.parentID, slug: info.slug, directory: info.directory, + path: info.path, title: info.title, version: info.version, share_url: info.share?.url, @@ -124,6 +127,10 @@ function getForkedTitle(title: string): string { return `${title} (fork #1)` } +function sessionPath(worktree: string, cwd: string) { + return path.relative(path.resolve(worktree), cwd).replaceAll("\\", "/") +} + const Summary = Schema.Struct({ additions: Schema.Number, deletions: Schema.Number, @@ -155,6 +162,7 @@ export const Info = Schema.Struct({ projectID: ProjectID, workspaceID: optionalOmitUndefined(WorkspaceID), directory: Schema.String, + path: optionalOmitUndefined(Schema.String), parentID: optionalOmitUndefined(SessionID), summary: optionalOmitUndefined(Summary), share: optionalOmitUndefined(Share), @@ -245,6 +253,7 @@ const UpdatedInfo = Schema.Struct({ projectID: Schema.optional(Schema.NullOr(ProjectID)), workspaceID: Schema.optional(Schema.NullOr(WorkspaceID)), directory: Schema.optional(Schema.NullOr(Schema.String)), + path: Schema.optional(Schema.NullOr(Schema.String)), parentID: Schema.optional(Schema.NullOr(SessionID)), summary: Schema.optional(Schema.NullOr(Summary)), share: Schema.optional(UpdatedShare), @@ -442,6 +451,7 @@ export const layer: Layer.Layer = parentID?: SessionID workspaceID?: WorkspaceID directory: string + path?: string permission?: Permission.Ruleset }) { const ctx = yield* InstanceState.context @@ -451,6 +461,7 @@ export const layer: Layer.Layer = version: InstallationVersion, projectID: ctx.project.id, directory: input.directory, + path: input.path, workspaceID: input.workspaceID, parentID: input.parentID, title: input.title ?? createDefaultTitle(!!input.parentID), @@ -566,11 +577,12 @@ export const layer: Layer.Layer = permission?: Permission.Ruleset workspaceID?: WorkspaceID }) { - const directory = yield* InstanceState.directory + const ctx = yield* InstanceState.context const workspace = yield* InstanceState.workspaceID return yield* createNext({ parentID: input?.parentID, - directory, + directory: ctx.directory, + path: sessionPath(ctx.worktree, ctx.directory), title: input?.title, permission: input?.permission, workspaceID: workspace, @@ -578,11 +590,12 @@ export const layer: Layer.Layer = }) const fork = Effect.fn("Session.fork")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { - const directory = yield* InstanceState.directory + const ctx = yield* InstanceState.context const original = yield* get(input.sessionID) const title = getForkedTitle(original.title) const session = yield* createNext({ - directory, + directory: ctx.directory, + path: sessionPath(ctx.worktree, ctx.directory), workspaceID: original.workspaceID, title, }) @@ -747,6 +760,8 @@ export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(S export function* list(input?: { directory?: string + scope?: "project" + path?: string workspaceID?: WorkspaceID roots?: boolean start?: number @@ -759,7 +774,17 @@ export function* list(input?: { if (input?.workspaceID) { conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) } - if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { + if (input?.path !== undefined) { + if (input.path) { + const conds = [eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`)] + + conditions.push( + input.directory + ? or(...conds, and(isNull(SessionTable.path), eq(SessionTable.directory, input.directory))!)! + : or(...conds)!, + ) + } + } else if (input?.scope !== "project" && !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { if (input?.directory) { conditions.push(eq(SessionTable.directory, input.directory)) } diff --git a/packages/opencode/src/storage/json-migration.ts b/packages/opencode/src/storage/json-migration.ts index db1ba75e063e..bc1aae6fa22b 100644 --- a/packages/opencode/src/storage/json-migration.ts +++ b/packages/opencode/src/storage/json-migration.ts @@ -208,6 +208,7 @@ export async function run(db: SQLiteBunDatabase | NodeSQLiteDatabase(schema: S) => Schema.optionalKey(schema).pipe( diff --git a/packages/opencode/test/cli/cmd/tui/sync.test.tsx b/packages/opencode/test/cli/cmd/tui/sync.test.tsx new file mode 100644 index 000000000000..993484d3cac0 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/sync.test.tsx @@ -0,0 +1,149 @@ +/** @jsxImportSource @opentui/solid */ +import { describe, expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { onMount } from "solid-js" +import { Global } from "@opencode-ai/core/global" +import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args" +import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit" +import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv" +import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project" +import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk" +import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync" +import { tmpdir } from "../../../fixture/fixture" + +const worktree = "/tmp/opencode" +const directory = `${worktree}/packages/opencode` + +async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +function json(data: unknown) { + return new Response(JSON.stringify(data), { + headers: { "content-type": "application/json" }, + }) +} + +function eventSource(): EventSource { + return { + subscribe: async () => () => {}, + } +} + +function createFetch() { + const session = [] as URL[] + const fetch = (async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)) + if (url.pathname === "/session") session.push(url) + + switch (url.pathname) { + case "/agent": + case "/command": + case "/experimental/workspace": + case "/experimental/workspace/status": + case "/formatter": + case "/lsp": + return json([]) + case "/config": + case "/experimental/resource": + case "/mcp": + case "/provider/auth": + case "/session/status": + return json({}) + case "/config/providers": + return json({ providers: {}, default: {} }) + case "/experimental/console": + return json({ consoleManagedProviders: [], switchableOrgCount: 0 }) + case "/path": + return json({ home: "", state: "", config: "", worktree, directory }) + case "/project/current": + return json({ id: "proj_test" }) + case "/provider": + return json({ all: [], default: {}, connected: [] }) + case "/session": + return json([]) + case "/vcs": + return json({ branch: "main" }) + } + + throw new Error(`unexpected request: ${url.pathname}`) + }) as typeof globalThis.fetch + + return { fetch, session } +} + +async function mount() { + const calls = createFetch() + let sync!: ReturnType + let kv!: ReturnType + let done!: () => void + const ready = new Promise((resolve) => { + done = resolve + }) + + const app = await testRender(() => ( + + + + + + + { + sync = ctx.sync + kv = ctx.kv + done() + }} + /> + + + + + + + )) + + await ready + await wait(() => sync.status === "complete") + return { app, kv, sync, session: calls.session } +} + +function Probe(props: { onReady: (ctx: { kv: ReturnType; sync: ReturnType }) => void }) { + const kv = useKV() + const sync = useSync() + + onMount(() => { + props.onReady({ kv, sync }) + }) + + return +} + +describe("tui sync", () => { + test("refresh scopes sessions by default and lists project sessions when disabled", async () => { + const previous = Global.Path.state + await using tmp = await tmpdir() + Global.Path.state = tmp.path + await Bun.write(`${tmp.path}/kv.json`, "{}") + const { app, kv, sync, session } = await mount() + + try { + expect(kv.get("session_directory_filter_enabled", true)).toBe(true) + expect(session.at(-1)?.searchParams.get("scope")).toBeNull() + expect(session.at(-1)?.searchParams.get("path")).toBe("packages/opencode") + + kv.set("session_directory_filter_enabled", false) + await sync.session.refresh() + + expect(session.at(-1)?.searchParams.get("scope")).toBe("project") + expect(session.at(-1)?.searchParams.get("path")).toBeNull() + } finally { + app.renderer.destroy() + Global.Path.state = previous + } + }) +}) diff --git a/packages/opencode/test/cli/tui/editor-context.test.ts b/packages/opencode/test/cli/tui/editor-context.test.ts index 767eeb8ec1a1..4c5491461e4e 100644 --- a/packages/opencode/test/cli/tui/editor-context.test.ts +++ b/packages/opencode/test/cli/tui/editor-context.test.ts @@ -10,12 +10,14 @@ type ZedFixtureOptions = { editor?: boolean selectionStart?: number | null selectionEnd?: number | null + contents?: string } async function writeZedFixture(dir: string, options: ZedFixtureOptions = {}) { const dbPath = path.join(dir, "zed.sqlite") const filePath = path.join(dir, "file.ts") - await Bun.write(filePath, "one\ntwo\nthree") + const contents = options.contents ?? "one\ntwo\nthree" + await Bun.write(filePath, contents) const db = new Database(dbPath) db.run("create table workspaces (workspace_id integer, paths text, timestamp text)") @@ -27,7 +29,7 @@ async function writeZedFixture(dir: string, options: ZedFixtureOptions = {}) { db.run("insert into panes values (1, 1, 1)") db.run("insert into items values (1, 1, 1, 1, ?)", [options.itemKind ?? "Editor"]) if (options.editor !== false) { - db.run("insert into editors values (1, 1, ?, ?)", [filePath, "one\ntwo\nthree"]) + db.run("insert into editors values (1, 1, ?, ?)", [filePath, contents]) db.run("insert into editor_selections values (1, 1, ?, ?)", [ options.selectionStart === undefined ? 4 : options.selectionStart, options.selectionEnd === undefined ? 7 : options.selectionEnd, @@ -38,11 +40,23 @@ async function writeZedFixture(dir: string, options: ZedFixtureOptions = {}) { return { dbPath, filePath } } +function utf8ByteOffset(text: string, offset: number) { + return new TextEncoder().encode(text.slice(0, offset)).length +} + test("offsetToPosition converts Zed offsets to 1-based editor positions", () => { expect(offsetToPosition("one\ntwo\nthree", 0)).toEqual({ line: 1, character: 1 }) expect(offsetToPosition("one\ntwo\nthree", 4)).toEqual({ line: 2, character: 1 }) expect(offsetToPosition("one\ntwo\nthree", 6)).toEqual({ line: 2, character: 3 }) expect(offsetToPosition("one\ntwo\nthree", 100)).toEqual({ line: 3, character: 6 }) + expect(offsetToPosition("Ж\nabc", utf8ByteOffset("Ж\nabc", "Ж\nabc".indexOf("a")))).toEqual({ + line: 2, + character: 1, + }) + expect(offsetToPosition("😀\nabc", utf8ByteOffset("😀\nabc", "😀\nabc".indexOf("a")))).toEqual({ + line: 2, + character: 1, + }) }) test("resolveZedSelection returns active editor selection", async () => { @@ -63,6 +77,102 @@ test("resolveZedSelection returns active editor selection", async () => { }) }) +test("resolveZedSelection converts Zed UTF-8 byte offsets to string offsets", async () => { + await using tmp = await tmpdir() + const contents = "a\nЖЖЖЖЖЖЖЖЖЖ\nb\nTARGET\nz" + const start = contents.indexOf("TARGET") + const fixture = await writeZedFixture(tmp.path, { + contents, + selectionStart: utf8ByteOffset(contents, start), + selectionEnd: utf8ByteOffset(contents, start + "TARGET".length), + }) + + expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ + type: "selection", + selection: { + text: "TARGET", + filePath: fixture.filePath, + source: "zed", + selection: { + start: { line: 4, character: 1 }, + end: { line: 4, character: 7 }, + }, + }, + }) +}) + +test("resolveZedSelection handles non-ASCII text inside the selected range", async () => { + await using tmp = await tmpdir() + const contents = "a\npre\nвыбор\nz" + const start = contents.indexOf("выбор") + const fixture = await writeZedFixture(tmp.path, { + contents, + selectionStart: utf8ByteOffset(contents, start), + selectionEnd: utf8ByteOffset(contents, start + "выбор".length), + }) + + expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ + type: "selection", + selection: { + text: "выбор", + filePath: fixture.filePath, + source: "zed", + selection: { + start: { line: 3, character: 1 }, + end: { line: 3, character: 6 }, + }, + }, + }) +}) + +test("resolveZedSelection handles emoji before the selected range", async () => { + await using tmp = await tmpdir() + const contents = "😀\nTARGET\nz" + const start = contents.indexOf("TARGET") + const fixture = await writeZedFixture(tmp.path, { + contents, + selectionStart: utf8ByteOffset(contents, start), + selectionEnd: utf8ByteOffset(contents, start + "TARGET".length), + }) + + expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ + type: "selection", + selection: { + text: "TARGET", + filePath: fixture.filePath, + source: "zed", + selection: { + start: { line: 2, character: 1 }, + end: { line: 2, character: 7 }, + }, + }, + }) +}) + +test("resolveZedSelection handles reversed Zed byte offsets", async () => { + await using tmp = await tmpdir() + const contents = "a\nЖЖЖ\nTARGET\nz" + const start = contents.indexOf("TARGET") + const fixture = await writeZedFixture(tmp.path, { + contents, + selectionStart: utf8ByteOffset(contents, start + "TARGET".length), + selectionEnd: utf8ByteOffset(contents, start), + }) + + expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ + type: "selection", + selection: { + text: "TARGET", + filePath: fixture.filePath, + source: "zed", + selection: { + start: { line: 3, character: 1 }, + end: { line: 3, character: 7 }, + }, + }, + }) +}) + test("resolveZedSelection returns empty when no workspace matches", async () => { await using tmp = await tmpdir() const fixture = await writeZedFixture(tmp.path, { diff --git a/packages/opencode/test/plugin/github-copilot-models.test.ts b/packages/opencode/test/plugin/github-copilot-models.test.ts index 33ddef5ddfac..939247f09b4e 100644 --- a/packages/opencode/test/plugin/github-copilot-models.test.ts +++ b/packages/opencode/test/plugin/github-copilot-models.test.ts @@ -117,6 +117,104 @@ test("preserves temperature support from existing provider models", async () => expect(models["brand-new"].capabilities.temperature).toBe(true) }) +test("clears existing variants so refreshed models calculate provider-specific variants", async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: [ + { + model_picker_enabled: true, + id: "claude-opus-4.7", + name: "Claude Opus 4.7", + version: "claude-opus-4.7-2026-04-16", + supported_endpoints: ["/v1/messages"], + capabilities: { + family: "claude-opus", + limits: { + max_context_window_tokens: 144000, + max_output_tokens: 64000, + max_prompt_tokens: 128000, + }, + supports: { + adaptive_thinking: true, + streaming: true, + tool_calls: true, + }, + }, + }, + ], + }), + { status: 200 }, + ), + ), + ) as unknown as typeof fetch + + const models = await CopilotModels.get( + "https://api.githubcopilot.com", + {}, + { + "claude-opus-4.7": { + id: "claude-opus-4.7", + providerID: "github-copilot", + api: { + id: "claude-opus-4.7", + url: "https://api.githubcopilot.com", + npm: "@ai-sdk/github-copilot", + }, + name: "Claude Opus 4.7", + family: "claude-opus", + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { + text: true, + audio: false, + image: true, + video: false, + pdf: false, + }, + output: { + text: true, + audio: false, + image: false, + video: false, + pdf: false, + }, + interleaved: false, + }, + cost: { + input: 0, + output: 0, + cache: { + read: 0, + write: 0, + }, + }, + limit: { + context: 144000, + input: 128000, + output: 64000, + }, + options: {}, + headers: {}, + release_date: "2026-04-16", + variants: { + low: { + reasoningEffort: "low", + }, + }, + status: "active", + }, + }, + ) + + expect(models["claude-opus-4.7"].api.npm).toBe("@ai-sdk/anthropic") + expect(models["claude-opus-4.7"].variants).toBeUndefined() +}) + test("remaps fallback oauth model urls to the enterprise host", async () => { globalThis.fetch = mock(() => Promise.reject(new Error("timeout"))) as unknown as typeof fetch diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index d9eb3640748d..c4831fa82f1c 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -855,6 +855,150 @@ describe("ProviderTransform.schema - gemini non-object properties removal", () = }) }) +describe("ProviderTransform.schema - moonshot $ref siblings", () => { + const moonshotModel = { + providerID: "moonshotai", + api: { + id: "kimi-k2", + }, + } as any + + test("removes sibling descriptions from referenced tool parameter schemas", () => { + const schema = { + type: "object", + properties: { + deviceType: { + description: "Optional. The type of device that captured the screenshot, e.g. mobile or desktop.", + enum: ["DEVICE_TYPE_UNSPECIFIED", "MOBILE", "DESKTOP", "TABLET", "AGNOSTIC"], + type: "string", + }, + modelId: { + description: "Optional. The model to use for generation.", + enum: ["MODEL_ID_UNSPECIFIED", "GEMINI_3_PRO", "GEMINI_3_FLASH", "GEMINI_3_1_PRO"], + type: "string", + }, + projectId: { + description: "Required. The project ID of screens to generate variants for.", + type: "string", + }, + prompt: { + description: "Required. The input text used to generate the variants.", + type: "string", + }, + selectedScreenIds: { + description: "Required. The screen ids of screen to generate variants for.", + items: { + type: "string", + }, + type: "array", + }, + variantOptions: { + $ref: "#/$defs/VariantOptions", + description: + "Required. The variant options for generation, including the number of variants, creative range, and aspects to focus on.", + }, + }, + required: ["projectId", "selectedScreenIds", "prompt", "variantOptions"], + $defs: { + VariantOptions: { + description: + "Configuration options for design variant generation. This message captures all parameters used to generate variants, allowing the configuration to be stored, replayed, or analyzed.", + properties: { + aspects: { + description: "Optional. Specific aspects to focus on. If empty, all aspects may be varied.", + items: { + enum: ["VARIANT_ASPECT_UNSPECIFIED", "LAYOUT", "COLOR_SCHEME", "IMAGES", "TEXT_FONT", "TEXT_CONTENT"], + type: "string", + }, + type: "array", + }, + creativeRange: { + description: "Optional. Creative range for variations. Default: EXPLORE", + enum: ["CREATIVE_RANGE_UNSPECIFIED", "REFINE", "EXPLORE", "REIMAGINE"], + type: "string", + }, + variantCount: { + description: "Optional. Number of variants to generate (1-5). Default: 3", + format: "int32", + type: "integer", + }, + }, + type: "object", + }, + }, + description: "Request message for GenerateVariants.", + additionalProperties: false, + } as any + + const result = ProviderTransform.schema(moonshotModel, schema) as any + + expect(result.properties.variantOptions).toEqual({ + $ref: "#/$defs/VariantOptions", + }) + expect(result.$defs.VariantOptions.description).toBe(schema.$defs.VariantOptions.description) + }) + + test("also runs for kimi models outside the moonshot provider", () => { + const result = ProviderTransform.schema( + { + providerID: "openrouter", + name: "Kimi K2", + api: { + id: "moonshotai/kimi-k2", + }, + } as any, + { + type: "object", + properties: { + value: { + $ref: "#/$defs/Value", + description: "Moonshot rejects this sibling after ref expansion.", + }, + }, + $defs: { + Value: { + description: "Referenced schema description stays here.", + type: "object", + }, + }, + } as any, + ) as any + + expect(result.properties.value).toEqual({ + $ref: "#/$defs/Value", + }) + }) + + test("converts tuple-style array items to a single item schema", () => { + const result = ProviderTransform.schema(moonshotModel, { + type: "object", + properties: { + codeSpec: { + type: "object", + properties: { + accessibility: { + type: "object", + properties: { + renderedSize: { + description: "Rendered size [width, height] in px", + type: "array", + items: [{ type: "number" }, { type: "number" }], + minItems: 2, + maxItems: 2, + }, + }, + }, + }, + }, + }, + } as any) as any + + expect(result.properties.codeSpec.properties.accessibility.properties.renderedSize.items).toEqual({ + type: "number", + }) + }) +}) + describe("ProviderTransform.message - DeepSeek reasoning content", () => { test("DeepSeek with tool calls includes reasoning_content in providerOptions", () => { const msgs = [ @@ -2773,6 +2917,28 @@ describe("ProviderTransform.variants", () => { }) }) + test("github copilot opus 4.7 returns only medium reasoning effort", () => { + const model = createMockModel({ + id: "claude-opus-4.7", + providerID: "github-copilot", + api: { + id: "claude-opus-4.7", + url: "https://api.githubcopilot.com/v1", + npm: "@ai-sdk/anthropic", + }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({ + medium: { + thinking: { + type: "adaptive", + display: "summarized", + }, + effort: "medium", + }, + }) + }) + test("returns high and max with thinking config", () => { const model = createMockModel({ id: "anthropic/claude-4", diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index c0482293b1f9..7a7105dfaa86 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -1,28 +1,13 @@ import { afterEach, describe, expect, test } from "bun:test" -import type { UpgradeWebSocket } from "hono/ws" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" -import { InstanceRoutes } from "../../src/server/routes/instance" -import { WorkspaceRoutes } from "../../src/server/routes/control/workspace" -import { ConfigApi } from "../../src/server/routes/instance/httpapi/config" -import { EventPaths } from "../../src/server/routes/instance/httpapi/event" -import { ExperimentalApi } from "../../src/server/routes/instance/httpapi/experimental" +import { ControlPaths } from "../../src/server/routes/instance/httpapi/control" import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/file" -import { InstanceApi } from "../../src/server/routes/instance/httpapi/instance" -import { McpApi } from "../../src/server/routes/instance/httpapi/mcp" -import { PermissionApi } from "../../src/server/routes/instance/httpapi/permission" -import { ProjectApi } from "../../src/server/routes/instance/httpapi/project" -import { ProviderApi } from "../../src/server/routes/instance/httpapi/provider" -import { PtyApi, PtyPaths } from "../../src/server/routes/instance/httpapi/pty" -import { QuestionApi } from "../../src/server/routes/instance/httpapi/question" -import { SessionApi } from "../../src/server/routes/instance/httpapi/session" -import { SyncApi } from "../../src/server/routes/instance/httpapi/sync" -import { TuiApi } from "../../src/server/routes/instance/httpapi/tui" -import { WorkspaceApi } from "../../src/server/routes/instance/httpapi/workspace" +import { GlobalPaths } from "../../src/server/routes/instance/httpapi/global" import { PublicApi } from "../../src/server/routes/instance/httpapi/public" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" -import { HttpApi, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { OpenApi } from "effect/unstable/httpapi" import { resetDatabase } from "../fixture/db" import { tmpdir } from "../fixture/fixture" @@ -34,48 +19,18 @@ const original = { OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, } -const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket const methods = ["get", "post", "put", "delete", "patch"] as const +let effectSpec: ReturnType | undefined + +function effectOpenApi() { + return (effectSpec ??= OpenApi.fromApi(PublicApi)) +} function app(input?: { password?: string; username?: string }) { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_SERVER_PASSWORD = input?.password Flag.OPENCODE_SERVER_USERNAME = input?.username - return InstanceRoutes(websocket) -} - -function routeKey(route: ReturnType["routes"][number]) { - return `${route.method} ${route.path}` -} - -function reflectedHttpApiRoutes() { - const routes = [`GET ${EventPaths.event}`, `GET ${PtyPaths.connect}`] - - function addRoutes(api: HttpApi.HttpApi) { - HttpApi.reflect(api, { - onGroup() {}, - onEndpoint({ endpoint }) { - routes.push(`${endpoint.method} ${endpoint.path}`) - }, - }) - } - - addRoutes(ConfigApi) - addRoutes(ExperimentalApi) - addRoutes(FileApi) - addRoutes(InstanceApi) - addRoutes(McpApi) - addRoutes(PermissionApi) - addRoutes(ProjectApi) - addRoutes(ProviderApi) - addRoutes(PtyApi) - addRoutes(QuestionApi) - addRoutes(SessionApi) - addRoutes(SyncApi) - addRoutes(TuiApi) - addRoutes(WorkspaceApi) - - return [...new Set(routes)] + return Server.Default().app } function openApiRouteKeys(spec: { paths: Record>> }) { @@ -86,6 +41,90 @@ function openApiRouteKeys(spec: { paths: Record>> }) { + return Object.fromEntries( + Object.entries(spec.paths).flatMap(([path, item]) => + methods + .filter((method) => item[method]) + .map((method) => [ + `${method.toUpperCase()} ${path}`, + (item[method]?.parameters ?? []) + .map(parameterKey) + .filter((param) => param !== undefined) + .sort(), + ]), + ), + ) +} + +function openApiRequestBodies(spec: { paths: Record>> }) { + return Object.fromEntries( + Object.entries(spec.paths).flatMap(([path, item]) => + methods + .filter((method) => item[method]) + .map((method) => [`${method.toUpperCase()} ${path}`, requestBodyKey(item[method]?.requestBody)]), + ), + ) +} + +type Operation = { + parameters?: unknown[] + responses?: unknown + requestBody?: unknown +} + +type RequestBody = { + content?: Record + required?: boolean +} + +function parameterKey(param: unknown) { + if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return + if (typeof param.in !== "string" || typeof param.name !== "string") return + return `${param.in}:${param.name}:${"required" in param && param.required === true}` +} + +function parameterSchema(input: { + spec: { paths: Record>> } + path: string + method: (typeof methods)[number] + name: string +}) { + const param = input.spec.paths[input.path]?.[input.method]?.parameters?.find( + (param) => !!param && typeof param === "object" && "name" in param && param.name === input.name, + ) + if (!param || typeof param !== "object" || !("schema" in param)) return + return param.schema +} + +function requestBodyKey(body: unknown) { + if (!body || typeof body !== "object" || !("content" in body)) return "" + const requestBody = body as RequestBody + return JSON.stringify({ + required: requestBody.required === true, + content: Object.entries(requestBody.content ?? {}) + .map(([type, value]) => [type, value.schema?.$ref ?? value.schema?.type ?? "inline"]) + .sort(), + }) +} + +function responseContentTypes(input: { + spec: { paths: Record>> } + path: string + method: (typeof methods)[number] + status: string +}) { + const responses = input.spec.paths[input.path]?.[input.method]?.responses + if (!responses || typeof responses !== "object" || !(input.status in responses)) return [] + const response = (responses as Record)[input.status] + if (!response || typeof response !== "object" || !("content" in response)) return [] + const content = (response as { content?: unknown }).content + if (!content || typeof content !== "object") { + return [] + } + return Object.keys(content).sort() +} + function authorization(username: string, password: string) { return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` } @@ -106,48 +145,71 @@ afterEach(async () => { await resetDatabase() }) -describe("HttpApi Hono bridge", () => { - test("mounts experimental handlers for every legacy instance route", () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false - const legacy = InstanceRoutes(websocket) - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - const experimental = InstanceRoutes(websocket) - - const bridge = experimental.routes.slice(0, experimental.routes.length - legacy.routes.length) - const workspaceRoutes = WorkspaceRoutes().routes.map((route) => ({ - ...route, - path: `/experimental/workspace${route.path === "/" ? "" : route.path}`, - })) - const legacyRoutes = [...new Set([...legacy.routes, ...workspaceRoutes].map(routeKey))] - const bridgeRoutes = new Set(bridge.map(routeKey)) - - expect(legacyRoutes.filter((route) => !bridgeRoutes.has(route))).toEqual([]) - expect([...bridgeRoutes].filter((route) => !legacyRoutes.includes(route)).sort()).toEqual([]) - }) - - test("mounts every Effect HttpApi route through the Hono bridge", () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false - const legacy = InstanceRoutes(websocket) - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - const experimental = InstanceRoutes(websocket) - - const bridgeRoutes = new Set( - experimental.routes.slice(0, experimental.routes.length - legacy.routes.length).map(routeKey), - ) - const httpApiRoutes = reflectedHttpApiRoutes() - - expect(httpApiRoutes.filter((route) => !bridgeRoutes.has(route))).toEqual([]) - expect([...bridgeRoutes].filter((route) => !httpApiRoutes.includes(route)).sort()).toEqual([]) - }) - +describe("HttpApi server", () => { test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => { const honoRoutes = openApiRouteKeys(await Server.openapi()) - const effectRoutes = openApiRouteKeys(OpenApi.fromApi(PublicApi)) + const effectRoutes = openApiRouteKeys(effectOpenApi()) expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([]) expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([]) }) + test("matches generated OpenAPI route parameters", async () => { + const hono = openApiParameters(await Server.openapi()) + const effect = openApiParameters(effectOpenApi()) + + expect( + Object.keys(hono) + .filter((route) => JSON.stringify(hono[route]) !== JSON.stringify(effect[route])) + .map((route) => ({ route, hono: hono[route], effect: effect[route] })), + ).toEqual([]) + }) + + test("matches generated OpenAPI request body shape", async () => { + const hono = openApiRequestBodies(await Server.openapi()) + const effect = openApiRequestBodies(effectOpenApi()) + + expect( + Object.keys(hono) + .filter((route) => hono[route] !== effect[route]) + .map((route) => ({ route, hono: hono[route], effect: effect[route] })), + ).toEqual([]) + }) + + test("matches SDK-affecting query parameter schemas", async () => { + const effect = effectOpenApi() + + expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "roots" })).toEqual({ + anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], + }) + expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "start" })).toEqual({ + type: "number", + }) + expect(parameterSchema({ spec: effect, path: "/find/file", method: "get", name: "limit" })).toEqual({ + type: "integer", + minimum: 1, + maximum: 200, + }) + expect( + parameterSchema({ spec: effect, path: "/session/{sessionID}/message", method: "get", name: "limit" }), + ).toEqual({ + type: "integer", + minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, + }) + }) + + test("documents event routes as server-sent events", () => { + const effect = effectOpenApi() + + expect(responseContentTypes({ spec: effect, path: "/event", method: "get", status: "200" })).toEqual([ + "text/event-stream", + ]) + expect(responseContentTypes({ spec: effect, path: "/global/event", method: "get", status: "200" })).toEqual([ + "text/event-stream", + ]) + }) + test("allows requests when auth is disabled", async () => { await using tmp = await tmpdir({ git: true }) await Bun.write(`${tmp.path}/hello.txt`, "hello") @@ -233,4 +295,55 @@ describe("HttpApi Hono bridge", () => { expect(response.status).toBe(200) expect(await response.json()).toMatchObject({ content: "query" }) }) + + test("serves global health from Effect HttpApi", async () => { + const response = await app().request(`${GlobalPaths.health}?directory=/does/not/exist/opencode-test`) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ healthy: true }) + }) + + test("serves global event stream from Effect HttpApi", async () => { + const response = await app().request(GlobalPaths.event) + if (!response.body) throw new Error("missing event stream body") + const reader = response.body.getReader() + const chunk = await reader.read() + await reader.cancel() + + expect(response.status).toBe(200) + expect(response.headers.get("content-type")).toContain("text/event-stream") + expect(new TextDecoder().decode(chunk.value)).toContain("server.connected") + }) + + test("serves control log from Effect HttpApi", async () => { + const response = await app().request(ControlPaths.log, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ service: "httpapi-test", level: "info", message: "hello" }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toBe(true) + }) + + test("validates control auth without falling through to 404", async () => { + const response = await app().request(ControlPaths.auth.replace(":providerID", "test"), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "api" }), + }) + + expect(response.status).toBe(400) + }) + + test("validates global upgrade without invoking installers", async () => { + const response = await app().request(GlobalPaths.upgrade, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not-json", + }) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ success: false }) + }) }) diff --git a/packages/opencode/test/server/httpapi-config.test.ts b/packages/opencode/test/server/httpapi-config.test.ts index 10a168414415..9469a66fd5a9 100644 --- a/packages/opencode/test/server/httpapi-config.test.ts +++ b/packages/opencode/test/server/httpapi-config.test.ts @@ -1,10 +1,9 @@ import { afterEach, describe, expect, test } from "bun:test" -import type { UpgradeWebSocket } from "hono/ws" import path from "path" import { Flag } from "@opencode-ai/core/flag/flag" import { GlobalBus } from "@/bus/global" import { Instance } from "../../src/project/instance" -import { InstanceRoutes } from "../../src/server/routes/instance" +import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" import { tmpdir } from "../fixture/fixture" @@ -12,11 +11,10 @@ import { tmpdir } from "../fixture/fixture" void Log.init({ print: false }) const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI -const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket function app() { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - return InstanceRoutes(websocket) + return Server.Default().app } async function waitDisposed(directory: string) { diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts index 4930ce7e7886..6fe92a23463b 100644 --- a/packages/opencode/test/server/httpapi-event.test.ts +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -1,8 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test" -import type { UpgradeWebSocket } from "hono/ws" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" -import { InstanceRoutes } from "../../src/server/routes/instance" +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" @@ -11,11 +10,10 @@ import { tmpdir } from "../fixture/fixture" void Log.init({ print: false }) const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI -const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket function app() { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - return InstanceRoutes(websocket) + return Server.Default().app } async function readFirstChunk(response: Response) { diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index cf0242048684..3978631b878a 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -1,10 +1,9 @@ import { afterEach, describe, expect, test } from "bun:test" -import type { UpgradeWebSocket } from "hono/ws" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { GlobalBus } from "@/bus/global" import { Instance } from "../../src/project/instance" -import { InstanceRoutes } from "../../src/server/routes/instance" +import { Server } from "../../src/server/server" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental" import { Session } from "@/session/session" import { Database } from "@/storage/db" @@ -16,12 +15,11 @@ import { tmpdir } from "../fixture/fixture" void Log.init({ print: false }) const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI -const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket const testWorktreeMutations = process.platform === "win32" ? test.skip : test function app() { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - return InstanceRoutes(websocket) + return Server.Default().app } function runSession(fx: Effect.Effect) { diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 65814ebde241..4ab1da11e64a 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -1,10 +1,9 @@ import { afterEach, describe, expect, test } from "bun:test" -import type { UpgradeWebSocket } from "hono/ws" import path from "path" import { Flag } from "@opencode-ai/core/flag/flag" import { GlobalBus } from "@/bus/global" import { Instance } from "../../src/project/instance" -import { InstanceRoutes } from "../../src/server/routes/instance" +import { Server } from "../../src/server/server" import { InstancePaths } from "../../src/server/routes/instance/httpapi/instance" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" @@ -13,11 +12,10 @@ import { tmpdir } from "../fixture/fixture" void Log.init({ print: false }) const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI -const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket function app() { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - return InstanceRoutes(websocket) + return Server.Default().app } async function waitDisposed(directory: string) { diff --git a/packages/opencode/test/server/httpapi-json-parity.test.ts b/packages/opencode/test/server/httpapi-json-parity.test.ts new file mode 100644 index 000000000000..555c717cf009 --- /dev/null +++ b/packages/opencode/test/server/httpapi-json-parity.test.ts @@ -0,0 +1,166 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { Instance } from "../../src/project/instance" +import { Server } from "../../src/server/server" +import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/session" +import { MessageID, PartID } from "../../src/session/schema" +import { Session } from "@/session/session" +import * as Log from "@opencode-ai/core/util/log" +import { resetDatabase } from "../fixture/db" +import { provideInstance, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" + +void Log.init({ print: false }) + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + +function app(experimental: boolean) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + return Server.Default().app +} +type TestApp = ReturnType + +function pathFor(path: string, params: Record) { + return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path) +} + +const seedSessions = Effect.gen(function* () { + const svc = yield* Session.Service + const parent = yield* svc.create({ title: "parent" }) + yield* svc.create({ title: "child", parentID: parent.id }) + const message = yield* svc.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: parent.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + yield* svc.updatePart({ + id: PartID.ascending(), + sessionID: parent.id, + messageID: message.id, + type: "text", + text: "hello", + }) + return { parent, message } +}) + +function withTmp( + options: Parameters[0], + fn: (tmp: Awaited>) => Effect.Effect, +) { + return Effect.acquireRelease( + Effect.promise(() => tmpdir(options)), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap((tmp) => fn(tmp).pipe(provideInstance(tmp.path)))) +} + +function readJson(label: string, serverApp: TestApp, path: string, headers: HeadersInit) { + return Effect.promise(async () => { + const response = await serverApp.request(path, { headers }) + if (response.status !== 200) throw new Error(`${label} returned ${response.status}: ${await response.text()}`) + return await response.json() + }) +} + +function expectJsonParity(input: { + label: string + legacy: TestApp + httpapi: TestApp + path: string + headers: HeadersInit +}) { + return Effect.gen(function* () { + const legacy = yield* readJson(input.label, input.legacy, input.path, input.headers) + const httpapi = yield* readJson(input.label, input.httpapi, input.path, input.headers) + expect({ label: input.label, body: httpapi }).toEqual({ label: input.label, body: legacy }) + return httpapi + }) +} + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await Instance.disposeAll() + await resetDatabase() +}) + +describe("HttpApi JSON parity", () => { + it.live( + "matches legacy JSON shape for session read endpoints", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const headers = { "x-opencode-directory": tmp.path } + const seeded = yield* seedSessions.pipe(Effect.provide(Session.defaultLayer)) + const legacy = app(false) + const httpapi = app(true) + + const rootsFalse = yield* expectJsonParity({ + label: "session.list roots false", + legacy, + httpapi, + path: `${SessionPaths.list}?roots=false`, + headers, + }) + expect((rootsFalse as Session.Info[]).map((session) => session.id)).toContain(seeded.parent.id) + expect((rootsFalse as Session.Info[]).length).toBe(2) + + const experimentalRootsFalse = yield* expectJsonParity({ + label: "experimental.session roots false", + legacy, + httpapi, + path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10", roots: "false" })}`, + headers, + }) + expect((experimentalRootsFalse as Session.GlobalInfo[]).length).toBe(2) + + const experimentalArchivedFalse = yield* expectJsonParity({ + label: "experimental.session archived false", + legacy, + httpapi, + path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10", archived: "false" })}`, + headers, + }) + expect((experimentalArchivedFalse as Session.GlobalInfo[]).length).toBe(2) + + yield* Effect.forEach( + [ + { label: "session.list roots", path: `${SessionPaths.list}?roots=true`, headers }, + { label: "session.list all", path: SessionPaths.list, headers }, + { label: "session.get", path: pathFor(SessionPaths.get, { sessionID: seeded.parent.id }), headers }, + { + label: "session.children", + path: pathFor(SessionPaths.children, { sessionID: seeded.parent.id }), + headers, + }, + { + label: "session.messages", + path: pathFor(SessionPaths.messages, { sessionID: seeded.parent.id }), + headers, + }, + { + label: "session.messages empty before", + path: `${pathFor(SessionPaths.messages, { sessionID: seeded.parent.id })}?before=`, + headers, + }, + { + label: "session.message", + path: pathFor(SessionPaths.message, { sessionID: seeded.parent.id, messageID: seeded.message.id }), + headers, + }, + { + label: "experimental.session", + path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10" })}`, + headers, + }, + ], + (input) => expectJsonParity({ ...input, legacy, httpapi }), + { concurrency: 1 }, + ) + }), + ), + ) +}) diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index 35ea3240ce7d..bb6635b52f00 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -1,15 +1,27 @@ import { afterEach, describe, expect, test } from "bun:test" -import { Context } from "effect" +import { Context, Effect, FileSystem, Layer, Path } from "effect" +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { Flag } from "@opencode-ai/core/flag/flag" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { McpPaths } from "../../src/server/routes/instance/httpapi/mcp" 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 { tmpdir } from "../fixture/fixture" +import { provideInstance, tmpdir } from "../fixture/fixture" +import { testEffect } from "../lib/effect" void Log.init({ print: false }) +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const context = Context.empty() as Context.Context +const it = testEffect(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)) + +function app(experimental: boolean) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + return Server.Default().app +} +type TestApp = ReturnType function request(route: string, directory: string, init?: RequestInit) { const headers = new Headers(init?.headers) @@ -23,7 +35,47 @@ function request(route: string, directory: string, init?: RequestInit) { ) } +function withMcpProject(self: (dir: string) => Effect.Effect) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" }) + + yield* fs.writeFileString( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + formatter: false, + lsp: false, + mcp: { + demo: { + type: "local", + command: ["echo", "demo"], + enabled: false, + }, + }, + }), + ) + yield* Effect.addFinalizer(() => + Effect.promise(() => Instance.provide({ directory: dir, fn: () => Instance.dispose() })).pipe(Effect.ignore), + ) + + return yield* self(dir).pipe(provideInstance(dir)) + }) +} + +const readResponse = Effect.fnUntraced(function* (input: { app: TestApp; path: string; headers: HeadersInit }) { + const response = yield* Effect.promise(() => + Promise.resolve(input.app.request(input.path, { method: "POST", headers: input.headers })), + ) + return { + status: response.status, + body: yield* Effect.promise(() => response.text()), + } +}) + afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await Instance.disposeAll() await resetDatabase() }) @@ -107,4 +159,28 @@ describe("mcp HttpApi", () => { expect(removed.status).toBe(200) expect(await removed.json()).toEqual({ success: true }) }) + + it.live( + "matches legacy unsupported OAuth error responses", + withMcpProject((dir) => + Effect.gen(function* () { + const headers = { "x-opencode-directory": dir } + const legacy = app(false) + const httpapi = app(true) + + yield* Effect.forEach(["/mcp/demo/auth", "/mcp/demo/auth/authenticate"], (path) => + Effect.gen(function* () { + const legacyResponse = yield* readResponse({ app: legacy, path, headers }) + const httpapiResponse = yield* readResponse({ app: httpapi, path, headers }) + + expect(legacyResponse).toEqual({ + status: 400, + body: JSON.stringify({ error: "MCP server demo does not support OAuth" }), + }) + expect(httpapiResponse).toEqual(legacyResponse) + }), + ) + }), + ), + ) }) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts new file mode 100644 index 000000000000..8d03311d912e --- /dev/null +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect, FileSystem, Layer, Path } from "effect" +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { Flag } from "@opencode-ai/core/flag/flag" +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 { provideInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +void Log.init({ print: false }) + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI +const it = testEffect(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)) +const providerID = "test-oauth-parity" +const oauthURL = "https://example.com/oauth" +const oauthInstructions = "Finish OAuth" + +function app(experimental: boolean) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + return Server.Default().app +} + +function requestAuthorize(input: { + app: ReturnType + providerID: string + method: number + headers: HeadersInit +}) { + return Effect.promise(async () => { + const response = await input.app.request(`/provider/${input.providerID}/oauth/authorize`, { + method: "POST", + headers: input.headers, + body: JSON.stringify({ method: input.method }), + }) + return { + status: response.status, + body: await response.text(), + } + }) +} + +function writeProviderAuthPlugin(dir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + yield* fs.makeDirectory(path.join(dir, ".opencode", "plugin"), { recursive: true }) + yield* fs.writeFileString( + path.join(dir, ".opencode", "plugin", "provider-oauth-parity.ts"), + [ + "export default {", + ' id: "test.provider-oauth-parity",', + " server: async () => ({", + " auth: {", + ` provider: "${providerID}",`, + " methods: [", + ' { type: "api", label: "API key" },', + " {", + ' type: "oauth",', + ' label: "OAuth",', + " authorize: async () => ({", + ` url: "${oauthURL}",`, + ' method: "code",', + ` instructions: "${oauthInstructions}",`, + " callback: async () => ({ type: 'success', key: 'token' }),", + " }),", + " },", + " ],", + " },", + " }),", + "}", + "", + ].join("\n"), + ) + }) +} + +function withProviderProject(self: (dir: string) => Effect.Effect) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" }) + + yield* fs.writeFileString( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json", formatter: false, lsp: false }), + ) + yield* writeProviderAuthPlugin(dir) + yield* Effect.addFinalizer(() => + Effect.promise(() => Instance.provide({ directory: dir, fn: () => Instance.dispose() })).pipe(Effect.ignore), + ) + + return yield* self(dir).pipe(provideInstance(dir)) + }) +} + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await Instance.disposeAll() + await resetDatabase() +}) + +describe("provider HttpApi", () => { + it.live( + "matches legacy OAuth authorize response shapes", + withProviderProject((dir) => + Effect.gen(function* () { + const headers = { "x-opencode-directory": dir, "content-type": "application/json" } + const legacy = app(false) + const httpapi = app(true) + + const apiLegacy = yield* requestAuthorize({ + app: legacy, + providerID, + method: 0, + headers, + }) + const apiHttpApi = yield* requestAuthorize({ + app: httpapi, + providerID, + method: 0, + headers, + }) + expect(apiLegacy).toEqual({ status: 200, body: "" }) + expect(apiHttpApi).toEqual(apiLegacy) + + const oauthLegacy = yield* requestAuthorize({ + app: legacy, + providerID, + method: 1, + headers, + }) + const oauthHttpApi = yield* requestAuthorize({ + app: httpapi, + providerID, + method: 1, + headers, + }) + expect(oauthHttpApi).toEqual(oauthLegacy) + expect(JSON.parse(oauthHttpApi.body)).toEqual({ + url: oauthURL, + method: "code", + instructions: oauthInstructions, + }) + }), + ), + ) +}) diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index ffaea3b75140..87e2a9412032 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -1,9 +1,8 @@ import { afterEach, describe, expect, test } from "bun:test" -import type { UpgradeWebSocket } from "hono/ws" import { Flag } from "@opencode-ai/core/flag/flag" import { PtyID } from "../../src/pty/schema" import { Instance } from "../../src/project/instance" -import { InstanceRoutes } from "../../src/server/routes/instance" +import { Server } from "../../src/server/server" import { PtyPaths } from "../../src/server/routes/instance/httpapi/pty" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" @@ -12,12 +11,11 @@ import { tmpdir } from "../fixture/fixture" void Log.init({ print: false }) const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI -const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket const testPty = process.platform === "win32" ? test.skip : test function app() { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - return InstanceRoutes(websocket) + return Server.Default().app } afterEach(async () => { diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts new file mode 100644 index 000000000000..d02285946935 --- /dev/null +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Flag } from "@opencode-ai/core/flag/flag" +import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" +import path from "path" +import { resetDatabase } from "../fixture/db" +import { tmpdir } from "../fixture/fixture" + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + +function client(directory?: string) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + const handler = ExperimentalHttpApiServer.webHandler().handler + const fetch = Object.assign( + (request: RequestInfo | URL, init?: RequestInit) => + handler(new Request(request, init), ExperimentalHttpApiServer.context), + { preconnect: globalThis.fetch.preconnect }, + ) satisfies typeof globalThis.fetch + return createOpencodeClient({ + baseUrl: "http://localhost", + directory, + fetch, + }) +} + +async function expectStatus(result: Promise<{ response: Response }>, status: number) { + expect((await result).response.status).toBe(status) +} + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await resetDatabase() +}) + +describe("HttpApi SDK", () => { + test("uses the generated SDK for global and control routes", async () => { + const sdk = client() + const health = await sdk.global.health() + + expect(health.response.status).toBe(200) + expect(health.data).toMatchObject({ healthy: true }) + + const events = await sdk.global.event({ signal: AbortSignal.timeout(1_000) }) + try { + const first = await events.stream.next() + expect(first.value).toMatchObject({ payload: { type: "server.connected" } }) + } finally { + await events.stream.return(undefined) + } + + const log = await sdk.app.log({ service: "httpapi-sdk-test", level: "info", message: "hello" }) + expect(log.response.status).toBe(200) + expect(log.data).toBe(true) + + await expectStatus(sdk.auth.set({ providerID: "test" }), 400) + }) + + test("uses the generated SDK for safe instance routes", async () => { + await using tmp = await tmpdir({ + config: { formatter: false, lsp: false }, + init: (dir) => Bun.write(path.join(dir, "hello.txt"), "hello"), + }) + const sdk = client(tmp.path) + + const file = await sdk.file.read({ path: "hello.txt" }) + expect(file.response.status).toBe(200) + expect(file.data).toMatchObject({ content: "hello" }) + + const session = await sdk.session.create({ title: "sdk" }) + expect(session.response.status).toBe(200) + expect(session.data).toMatchObject({ title: "sdk" }) + + const listed = await sdk.session.list({ roots: true, limit: 10 }) + expect(listed.response.status).toBe(200) + expect(listed.data?.map((item) => item.id)).toContain(session.data?.id) + + await Promise.all([ + expectStatus(sdk.project.current(), 200), + expectStatus(sdk.config.get(), 200), + expectStatus(sdk.config.providers(), 200), + expectStatus(sdk.find.files({ query: "hello", limit: 10 }), 200), + ]) + }) +}) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index aa7e33a034ec..3e3fb3573104 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -1,11 +1,10 @@ import { afterEach, describe, expect } from "bun:test" -import type { UpgradeWebSocket } from "hono/ws" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { PermissionID } from "../../src/permission/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instance } from "../../src/project/instance" -import { InstanceRoutes } from "../../src/server/routes/instance" +import { Server } from "../../src/server/server" import { SessionPaths } from "../../src/server/routes/instance/httpapi/session" import { Session } from "@/session/session" import { MessageID, PartID, type SessionID } from "../../src/session/schema" @@ -18,11 +17,10 @@ import { it } from "../lib/effect" void Log.init({ print: false }) const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI -const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket function app() { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - return InstanceRoutes(websocket) + return Server.Default().app } function runSession(fx: Effect.Effect) { diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index 75e67db468fd..275819105798 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -1,9 +1,8 @@ import { afterEach, describe, expect, test } from "bun:test" -import type { UpgradeWebSocket } from "hono/ws" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" -import { InstanceRoutes } from "../../src/server/routes/instance" +import { Server } from "../../src/server/server" import { SyncPaths } from "../../src/server/routes/instance/httpapi/sync" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" @@ -14,11 +13,10 @@ void Log.init({ print: false }) const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES -const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket -function app() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - return InstanceRoutes(websocket) +function app(httpapi = true) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpapi + return Server.Default().app } function runSession(fx: Effect.Effect) { @@ -81,4 +79,48 @@ describe("sync HttpApi", () => { expect(replayed.status).toBe(200) expect(await replayed.json()).toEqual({ sessionID: session.id }) }) + + test("matches legacy seq validation", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } + const cases = [ + { + path: SyncPaths.history, + body: { aggregate: -1 }, + }, + { + path: SyncPaths.history, + body: { aggregate: 1.5 }, + }, + { + path: SyncPaths.replay, + body: { + directory: tmp.path, + events: [{ id: "event", aggregateID: "session", seq: -1, type: "session.created", data: {} }], + }, + }, + { + path: SyncPaths.replay, + body: { + directory: tmp.path, + events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }], + }, + }, + ] + + for (const item of cases) { + const legacy = await app(false).request(item.path, { + method: "POST", + headers, + body: JSON.stringify(item.body), + }) + const httpapi = await app(true).request(item.path, { + method: "POST", + headers, + body: JSON.stringify(item.body), + }) + expect(httpapi.status).toBe(legacy.status) + expect(httpapi.status).toBe(400) + } + }) }) diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts index f47d11c67060..81a2105095f9 100644 --- a/packages/opencode/test/server/httpapi-tui.test.ts +++ b/packages/opencode/test/server/httpapi-tui.test.ts @@ -1,24 +1,23 @@ import { afterEach, describe, expect, test } from "bun:test" import type { Context } from "hono" -import type { UpgradeWebSocket } from "hono/ws" import { Flag } from "@opencode-ai/core/flag/flag" import { SessionID } from "../../src/session/schema" import { Instance } from "../../src/project/instance" -import { InstanceRoutes } from "../../src/server/routes/instance" -import { TuiPaths } from "../../src/server/routes/instance/httpapi/tui" +import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/tui" import { callTui } from "../../src/server/routes/instance/tui" +import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" +import { OpenApi } from "effect/unstable/httpapi" import { resetDatabase } from "../fixture/db" import { tmpdir } from "../fixture/fixture" void Log.init({ print: false }) const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI -const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket function app() { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - return InstanceRoutes(websocket) + return Server.Default().app } async function expectTrue(path: string, headers: Record, body?: unknown) { @@ -38,6 +37,15 @@ afterEach(async () => { }) describe("tui HttpApi bridge", () => { + test("documents legacy bad request responses", async () => { + const legacy = await Server.openapi() + const effect = OpenApi.fromApi(TuiApi) + for (const path of [TuiPaths.appendPrompt, TuiPaths.executeCommand, TuiPaths.publish, TuiPaths.selectSession]) { + expect(legacy.paths[path].post?.responses?.[400]).toBeDefined() + expect(effect.paths[path].post?.responses?.[400]).toBeDefined() + } + }) + test("serves TUI command and event routes through experimental Effect routes", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const headers = { "x-opencode-directory": tmp.path } diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 180d83ee4d8d..cb549c649750 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -2,7 +2,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" import { Effect } from "effect" -import type { UpgradeWebSocket } from "hono/ws" import { Flag } from "@opencode-ai/core/flag/flag" import { registerAdaptor } from "../../src/control-plane/adaptors" import type { WorkspaceAdaptor } from "../../src/control-plane/types" @@ -10,7 +9,7 @@ import { Workspace } from "../../src/control-plane/workspace" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/workspace" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" -import { InstanceRoutes } from "../../src/server/routes/instance" +import { Server } from "../../src/server/server" import { resetDatabase } from "../fixture/db" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" @@ -19,13 +18,12 @@ void Log.init({ print: false }) const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI -const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket function request(path: string, directory: string, init: RequestInit = {}) { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true const headers = new Headers(init.headers) headers.set("x-opencode-directory", directory) - return InstanceRoutes(websocket).request(path, { ...init, headers }) + return Server.Default().app.request(path, { ...init, headers }) } function runSession(fx: Effect.Effect) { diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 2e2945d07520..cbdda6b42648 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -4,8 +4,15 @@ import { Instance } from "../../src/project/instance" import { Session as SessionNs } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { tmpdir } from "../fixture/fixture" +import { Flag } from "@opencode-ai/core/flag/flag" +import { mkdir } from "fs/promises" +import path from "path" +import { Database } from "@/storage/db" +import { SessionTable } from "@/session/session.sql" +import { eq } from "drizzle-orm" void Log.init({ print: false }) +const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES function run(fx: Effect.Effect) { return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) @@ -19,28 +26,140 @@ const svc = { } afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await Instance.disposeAll() }) describe("session.list", () => { - test("filters by directory", async () => { + test("does not filter by directory when directory is omitted", async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false await using tmp = await tmpdir({ git: true }) + await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true }) + await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) + await Instance.provide({ directory: tmp.path, fn: async () => { - const first = await svc.create({}) + const root = await svc.create({ title: "root" }) - await using other = await tmpdir({ git: true }) - const second = await Instance.provide({ - directory: other.path, - fn: async () => svc.create({}), + const parent = await Instance.provide({ + directory: path.join(tmp.path, "packages"), + fn: async () => svc.create({ title: "parent" }), + }) + const current = await Instance.provide({ + directory: path.join(tmp.path, "packages", "opencode"), + fn: async () => svc.create({ title: "current" }), + }) + const sibling = await Instance.provide({ + directory: path.join(tmp.path, "packages", "app"), + fn: async () => svc.create({ title: "sibling" }), }) - const sessions = [...svc.list({ directory: tmp.path })] - const ids = sessions.map((s) => s.id) + const ids = [...svc.list()].map((s) => s.id) + expect(ids).toContain(root.id) + expect(ids).toContain(parent.id) + expect(ids).toContain(current.id) + expect(ids).toContain(sibling.id) + }, + }) + }) + + test("filters by directory when directory is provided", async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false + await using tmp = await tmpdir({ git: true }) + await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true }) + await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const root = await svc.create({ title: "root" }) + + const parent = await Instance.provide({ + directory: path.join(tmp.path, "packages"), + fn: async () => svc.create({ title: "parent" }), + }) + const current = await Instance.provide({ + directory: path.join(tmp.path, "packages", "opencode"), + fn: async () => svc.create({ title: "current" }), + }) + const sibling = await Instance.provide({ + directory: path.join(tmp.path, "packages", "app"), + fn: async () => svc.create({ title: "sibling" }), + }) + + const ids = [...svc.list({ directory: path.join(tmp.path, "packages", "opencode") })].map((s) => s.id) + expect(ids).not.toContain(root.id) + expect(ids).not.toContain(parent.id) + expect(ids).toContain(current.id) + expect(ids).not.toContain(sibling.id) + }, + }) + }) + + test("filters by path and ignores directory when path is provided", async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false + await using tmp = await tmpdir({ git: true }) + await mkdir(path.join(tmp.path, "packages", "opencode", "src", "deep"), { recursive: true }) + await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const parent = await Instance.provide({ + directory: path.join(tmp.path, "packages", "opencode"), + fn: async () => svc.create({ title: "parent" }), + }) + const current = await Instance.provide({ + directory: path.join(tmp.path, "packages", "opencode", "src"), + fn: async () => svc.create({ title: "current" }), + }) + const deeper = await Instance.provide({ + directory: path.join(tmp.path, "packages", "opencode", "src", "deep"), + fn: async () => svc.create({ title: "deeper" }), + }) + const sibling = await Instance.provide({ + directory: path.join(tmp.path, "packages", "app"), + fn: async () => svc.create({ title: "sibling" }), + }) + + const pathIDs = [ + ...svc.list({ directory: path.join(tmp.path, "packages", "app"), path: "packages/opencode/src" }), + ].map((s) => s.id) + expect(pathIDs).not.toContain(parent.id) + expect(pathIDs).toContain(current.id) + expect(pathIDs).toContain(deeper.id) + expect(pathIDs).not.toContain(sibling.id) + }, + }) + }) + + test("falls back to directory when filtering legacy sessions without path", async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false + await using tmp = await tmpdir({ git: true }) + await mkdir(path.join(tmp.path, "packages", "opencode", "src"), { recursive: true }) + await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const current = await Instance.provide({ + directory: path.join(tmp.path, "packages", "opencode", "src"), + fn: async () => svc.create({ title: "legacy-current" }), + }) + const sibling = await Instance.provide({ + directory: path.join(tmp.path, "packages", "app"), + fn: async () => svc.create({ title: "legacy-sibling" }), + }) + + Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run()) + Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run()) - expect(ids).toContain(first.id) - expect(ids).not.toContain(second.id) + const pathIDs = [ + ...svc.list({ directory: path.join(tmp.path, "packages", "opencode", "src"), path: "packages/opencode/src" }), + ].map((s) => s.id) + expect(pathIDs).toContain(current.id) + expect(pathIDs).not.toContain(sibling.id) }, }) }) diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index 6ac695cd6cb0..abe99dddc72f 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -59,6 +59,7 @@ describe("Session.Info", () => { projectID, workspaceID, directory: "/tmp/proj", + path: "packages/opencode", parentID: sessionIDChild, summary: { additions: 10, diff --git a/packages/opencode/test/session/session-schema.test.ts b/packages/opencode/test/session/session-schema.test.ts index cefe6e73af12..38531d15b49a 100644 --- a/packages/opencode/test/session/session-schema.test.ts +++ b/packages/opencode/test/session/session-schema.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { Schema } from "effect" import { ProjectID } from "../../src/project/schema" -import { SessionID } from "../../src/session/schema" +import { MessageID, SessionID } from "../../src/session/schema" import { Session } from "../../src/session/session" const info = { @@ -50,4 +50,27 @@ describe("Session schema", () => { expect(Object.hasOwn(encoded, "parentID")).toBe(false) expect(Object.hasOwn(encoded.project as Record, "name")).toBe(false) }) + + test("encodes nested undefined optional session fields as omitted keys", () => { + const encoded = Schema.encodeUnknownSync(Session.Info)({ + ...info, + summary: { + additions: 1, + deletions: 2, + files: 3, + diffs: undefined, + }, + revert: { + messageID: MessageID.ascending(), + partID: undefined, + snapshot: undefined, + diff: undefined, + }, + }) as Record + + expect(Object.hasOwn(encoded.summary as Record, "diffs")).toBe(false) + for (const key of ["partID", "snapshot", "diff"]) { + expect(Object.hasOwn(encoded.revert as Record, key)).toBe(false) + } + }) }) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index c1ff9ae03cd1..99f20b44dcbb 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -54,6 +54,7 @@ describe("session.created event", () => { expect(receivedInfo?.id).toBe(info.id) expect(receivedInfo?.projectID).toBe(info.projectID) expect(receivedInfo?.directory).toBe(info.directory) + expect(receivedInfo?.path).toBe(info.path) expect(receivedInfo?.title).toBe(info.title) await remove(info.id) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index fb213c7cc82b..a8517522c475 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.28", + "version": "1.14.29", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index c59f290962d6..515b693a5653 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.28", + "version": "1.14.29", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index 268233a012e9..e920cc0fdb15 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -9,7 +9,14 @@ import path from "path" import { createClient } from "@hey-api/openapi-ts" -await $`bun dev generate > ${dir}/openapi.json`.cwd(path.resolve(dir, "../../opencode")) +const openapiSource = process.env.OPENCODE_SDK_OPENAPI === "httpapi" ? "httpapi" : "hono" +const opencode = path.resolve(dir, "../../opencode") + +if (openapiSource === "httpapi") { + await $`bun dev generate --httpapi > ${dir}/openapi.json`.cwd(opencode) +} else { + await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode) +} await createClient({ input: "./openapi.json", diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index e21d1f496880..2da7c865d770 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -848,12 +848,12 @@ export class Session extends HeyApiClient { parameters?: { directory?: string workspace?: string - roots?: boolean + roots?: boolean | "true" | "false" start?: number cursor?: number search?: string limit?: number - archived?: boolean + archived?: boolean | "true" | "false" }, options?: Options, ) { @@ -1647,7 +1647,9 @@ export class Session2 extends HeyApiClient { parameters?: { directory?: string workspace?: string - roots?: boolean + scope?: "project" + path?: string + roots?: boolean | "true" | "false" start?: number search?: string limit?: number @@ -1661,6 +1663,8 @@ export class Session2 extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "scope" }, + { in: "query", key: "path" }, { in: "query", key: "roots" }, { in: "query", key: "start" }, { in: "query", key: "search" }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b034777f2552..b6ab684678e9 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -936,6 +936,7 @@ export type Session = { projectID: string workspaceID?: string directory: string + path?: string parentID?: string summary?: { additions: number @@ -1063,6 +1064,7 @@ export type SyncEventSessionUpdated = { projectID?: string | null workspaceID?: string | null directory?: string | null + path?: string | null parentID?: string | null summary?: { additions: number @@ -1882,6 +1884,7 @@ export type GlobalSession = { projectID: string workspaceID?: string directory: string + path?: string parentID?: string summary?: { additions: number @@ -2129,6 +2132,10 @@ export type McpStatus = | McpStatusNeedsAuth | McpStatusNeedsClientRegistration +export type McpUnsupportedOAuthError = { + error: string +} + export type Path = { home: string state: string @@ -3217,7 +3224,7 @@ export type ExperimentalSessionListData = { /** * Only return root sessions (no parentID) */ - roots?: boolean + roots?: boolean | "true" | "false" /** * Filter sessions updated on or after this timestamp (milliseconds since epoch) */ @@ -3237,7 +3244,7 @@ export type ExperimentalSessionListData = { /** * Include archived sessions (default false) */ - archived?: boolean + archived?: boolean | "true" | "false" } url: "/experimental/session" } @@ -3278,14 +3285,22 @@ export type SessionListData = { path?: never query?: { /** - * Filter sessions by project directory + * Filter sessions by directory */ directory?: string workspace?: string + /** + * List all sessions for the current project + */ + scope?: "project" + /** + * Filter sessions by project-relative path + */ + path?: string /** * Only return root sessions (no parentID) */ - roots?: boolean + roots?: boolean | "true" | "false" /** * Filter sessions updated on or after this timestamp (milliseconds since epoch) */ @@ -4907,9 +4922,9 @@ export type McpAuthStartData = { export type McpAuthStartErrors = { /** - * Bad request + * MCP server does not support OAuth */ - 400: BadRequestError + 400: McpUnsupportedOAuthError /** * Not found */ @@ -4985,9 +5000,9 @@ export type McpAuthAuthenticateData = { export type McpAuthAuthenticateErrors = { /** - * Bad request + * MCP server does not support OAuth */ - 400: BadRequestError + 400: McpUnsupportedOAuthError /** * Not found */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index e9e493bda88d..d79103df6456 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -2168,7 +2168,15 @@ "in": "query", "name": "roots", "schema": { - "type": "boolean" + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": ["true", "false"] + } + ] }, "description": "Only return root sessions (no parentID)" }, @@ -2208,7 +2216,15 @@ "in": "query", "name": "archived", "schema": { - "type": "boolean" + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": ["true", "false"] + } + ] }, "description": "Include archived sessions (default false)" } @@ -2295,7 +2311,7 @@ "schema": { "type": "string" }, - "description": "Filter sessions by project directory" + "description": "Filter sessions by directory" }, { "in": "query", @@ -2304,11 +2320,36 @@ "type": "string" } }, + { + "in": "query", + "name": "scope", + "schema": { + "type": "string", + "enum": ["project"] + }, + "description": "List all sessions for the current project" + }, + { + "in": "query", + "name": "path", + "schema": { + "type": "string" + }, + "description": "Filter sessions by project-relative path" + }, { "in": "query", "name": "roots", "schema": { - "type": "boolean" + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": ["true", "false"] + } + ] }, "description": "Only return root sessions (no parentID)" }, @@ -6089,11 +6130,11 @@ } }, "400": { - "description": "Bad request", + "description": "MCP server does not support OAuth", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" + "$ref": "#/components/schemas/McpUnsupportedOAuthError" } } } @@ -6307,11 +6348,11 @@ } }, "400": { - "description": "Bad request", + "description": "MCP server does not support OAuth", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" + "$ref": "#/components/schemas/McpUnsupportedOAuthError" } } } @@ -10130,6 +10171,9 @@ "directory": { "type": "string" }, + "path": { + "type": "string" + }, "parentID": { "type": "string", "pattern": "^ses.*" @@ -10560,6 +10604,16 @@ } ] }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "parentID": { "anyOf": [ { @@ -12514,6 +12568,9 @@ "directory": { "type": "string" }, + "path": { + "type": "string" + }, "parentID": { "type": "string", "pattern": "^ses.*" @@ -13252,6 +13309,15 @@ } ] }, + "McpUnsupportedOAuthError": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + }, "Path": { "type": "object", "properties": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 7b256efe2023..db534d36153c 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.28", + "version": "1.14.29", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index c9d9ea98ca52..3f8c02b3a0c0 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.28", + "version": "1.14.29", "type": "module", "license": "MIT", "exports": { diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index 198412dcb9f9..c2e50c4b97df 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -11,17 +11,6 @@ cursor: pointer; } - &[data-hide-details="true"] { - [data-slot="basic-tool-tool-trigger-content"] { - flex: 1 1 auto; - max-width: 100%; - } - - [data-slot="basic-tool-tool-info"] { - flex: 1 1 auto; - } - } - [data-slot="basic-tool-tool-trigger-content"] { flex: 0 1 auto; width: auto; diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index c84a36892242..0dd02d812940 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -1207,7 +1207,6 @@ [data-slot="apply-patch-filename"] { color: var(--text-strong); - flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; diff --git a/packages/web/package.json b/packages/web/package.json index 5e07e924358b..199d315d2132 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.28", + "version": "1.14.29", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 52cf9105d254..b3adfa26947d 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.28", + "version": "1.14.29", "publisher": "sst-dev", "repository": { "type": "git",