mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73ea130d16 |
+1
-4
@@ -11,7 +11,6 @@ adamdotdevin
|
|||||||
-agusbasari29 AI PR slop
|
-agusbasari29 AI PR slop
|
||||||
ariane-emory
|
ariane-emory
|
||||||
-atharvau AI review spamming literally every PR
|
-atharvau AI review spamming literally every PR
|
||||||
-borealbytes
|
|
||||||
-danieljoshuanazareth
|
-danieljoshuanazareth
|
||||||
-danieljoshuanazareth
|
-danieljoshuanazareth
|
||||||
edemaine
|
edemaine
|
||||||
@@ -22,10 +21,8 @@ jayair
|
|||||||
kitlangton
|
kitlangton
|
||||||
kommander
|
kommander
|
||||||
-opencode2026
|
-opencode2026
|
||||||
-opencodeengineer bot that spams issues
|
|
||||||
r44vc0rp
|
r44vc0rp
|
||||||
rekram1-node
|
rekram1-node
|
||||||
-robinmordasiewicz
|
|
||||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||||
thdxr
|
thdxr
|
||||||
-toastythebot
|
-OpenCodeEngineer bot that spams issues
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -140,7 +140,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.64",
|
"@ai-sdk/anthropic": "3.0.64",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop-electron": {
|
"packages/desktop-electron": {
|
||||||
"name": "@opencode-ai/desktop-electron",
|
"name": "@opencode-ai/desktop-electron",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -252,7 +252,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
@@ -281,7 +281,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -297,7 +297,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -338,8 +338,8 @@
|
|||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "2.3.3",
|
"@openrouter/ai-sdk-provider": "2.3.3",
|
||||||
"@opentui/core": "0.1.95",
|
"@opentui/core": "0.1.93",
|
||||||
"@opentui/solid": "0.1.95",
|
"@opentui/solid": "0.1.93",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
@@ -423,22 +423,22 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@opentui/core": "0.1.95",
|
"@opentui/core": "0.1.93",
|
||||||
"@opentui/solid": "0.1.95",
|
"@opentui/solid": "0.1.93",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentui/core": ">=0.1.95",
|
"@opentui/core": ">=0.1.93",
|
||||||
"@opentui/solid": ">=0.1.95",
|
"@opentui/solid": ">=0.1.93",
|
||||||
},
|
},
|
||||||
"optionalPeers": [
|
"optionalPeers": [
|
||||||
"@opentui/core",
|
"@opentui/core",
|
||||||
@@ -457,7 +457,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "0.90.10",
|
"@hey-api/openapi-ts": "0.90.10",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
@@ -468,7 +468,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -503,7 +503,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -550,7 +550,7 @@
|
|||||||
},
|
},
|
||||||
"packages/util": {
|
"packages/util": {
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -561,7 +561,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -612,7 +612,7 @@
|
|||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@cloudflare/workers-types": "4.20251008.0",
|
"@cloudflare/workers-types": "4.20251008.0",
|
||||||
"@effect/platform-node": "4.0.0-beta.43",
|
"@effect/platform-node": "4.0.0-beta.42",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
@@ -636,7 +636,7 @@
|
|||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||||
"effect": "4.0.0-beta.43",
|
"effect": "4.0.0-beta.42",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
@@ -995,9 +995,9 @@
|
|||||||
|
|
||||||
"@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="],
|
"@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="],
|
||||||
|
|
||||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.43", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.43", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43", "ioredis": "^5.7.0" } }, "sha512-Uq6E1rjaIpjHauzjwoB2HzAg3battYt2Boy8XO50GoHiWCXKE6WapYZ0/AnaBx5v5qg2sOfqpuiLsUf9ZgxOkA=="],
|
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.42", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.42", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.42", "ioredis": "^5.7.0" } }, "sha512-kbdRML2FBa4q8U8rZQcnmLKZ5zN/z1bAA7t5D1/UsBHZqJgnfRgu1CP6kaEfb1Nie6YyaWshxTktZQryjvW/Yg=="],
|
||||||
|
|
||||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.43", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-A9q0GEb61pYcQ06Dr6gXj1nKlDI3KHsar1sk3qb1ZY+kVSR64tBAylI8zGon23KY+NPtTUj/sEIToB7jc3Qt5w=="],
|
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.42", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.42" } }, "sha512-PC+lxLsrwob3+nBChAPrQq32olCeyApgXBvs1NrRsoArLViNT76T/68CttuCAksCZj5e1bZ1ZibLPel3vUmx2g=="],
|
||||||
|
|
||||||
"@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=="],
|
"@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=="],
|
||||||
|
|
||||||
@@ -1461,21 +1461,21 @@
|
|||||||
|
|
||||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||||
|
|
||||||
"@opentui/core": ["@opentui/core@0.1.95", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.95", "@opentui/core-darwin-x64": "0.1.95", "@opentui/core-linux-arm64": "0.1.95", "@opentui/core-linux-x64": "0.1.95", "@opentui/core-win32-arm64": "0.1.95", "@opentui/core-win32-x64": "0.1.95", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Ha73I+PPSy6Jk8CTZgdGRHU+nnmrPAs7m6w0k6ge1/kWbcNcZB0lY67sWQMdoa6bSINQMNWg7SjbNCC9B/0exg=="],
|
"@opentui/core": ["@opentui/core@0.1.93", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.93", "@opentui/core-darwin-x64": "0.1.93", "@opentui/core-linux-arm64": "0.1.93", "@opentui/core-linux-x64": "0.1.93", "@opentui/core-win32-arm64": "0.1.93", "@opentui/core-win32-x64": "0.1.93", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-HlTM16ZiBKN0mPBNMHSILkSrbzNku6Pg/ovIpVVkEPqLeWeSC2bfZS4Uhc0Ej1sckVVVoU9HKBJanfHvpP+pMg=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.95", "", { "os": "darwin", "cpu": "arm64" }, "sha512-92joqr0ucGaIBCl9uYhe5DwAPbgGMTaCsCeY8Yf3VQ72wjGbOTwnC1TvU5wC6bUmiyqfijCqMyuUnj83teIVVQ=="],
|
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.93", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4I2mwhXLqRNUv7tu88hA6cBGaGpLZXkAa8W0VqBiGDV+Tx337x4T+vbQ7G57OwKXT787oTrEOF9rOOrGLov6qw=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.95", "", { "os": "darwin", "cpu": "x64" }, "sha512-+TLL3Kp3x7DTWEAkCAYe+RjRhl58QndoeXMstZNS8GQyrjSpUuivzwidzAz0HZK9SbZJfvaxZmXsToAIdI2fag=="],
|
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.93", "", { "os": "darwin", "cpu": "x64" }, "sha512-jvYMgcg47a5qLhSv1DnQiafEWBQ1UukGutmsYV1TvNuhWtuDXYLVy2AhKIHPzbB9JNrV0IpjbxUC8QnJaP3n8g=="],
|
||||||
|
|
||||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.95", "", { "os": "linux", "cpu": "arm64" }, "sha512-dAYeRqh7P8o0xFZleDDR1Abt4gSvCISqw6syOrbH3dl7pMbVdGgzA5stM9jqMgdPUVE7Ngumo17C23ehkGv93A=="],
|
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.93", "", { "os": "linux", "cpu": "arm64" }, "sha512-bvFqRcPftmg14iYmMc3d63XC9rhe4yF7pJRApH6klLBKp27WX/LU0iSO4mvyX7qhy65gcmyy4Sj9dl5jNJ+vlA=="],
|
||||||
|
|
||||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.95", "", { "os": "linux", "cpu": "x64" }, "sha512-O54TCgK8E7j2NKrDXUOTZqO4sb8JjeAfnhrStxAMMEw4RFCGWx3p3wLesqR16uKfFFJFDyoh2OWZ698tO88EAA=="],
|
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.93", "", { "os": "linux", "cpu": "x64" }, "sha512-/wJXhwtNxdcpshrRl1KouyGE54ODAHxRQgBHtnlM/F4bB8cjzOlq2Yc+5cv5DxRz4Q0nQZFCPefwpg2U6ZwNdA=="],
|
||||||
|
|
||||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.95", "", { "os": "win32", "cpu": "arm64" }, "sha512-T1RlZ6U/95eYDN6rUm4SLOVA5LBR7iL3TcBroQhV/883bVczXIBPhriEXQayup5FsAemnQba1BzMNvy6128SUw=="],
|
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.93", "", { "os": "win32", "cpu": "arm64" }, "sha512-g3PQobfM2yFPSzkBKRKFp8FgTG4ulWyJcU+GYXjyYmxQIT+ZbOU7UfR//ImRq3/FxUAfUC/MhC6WwjqccjEqBw=="],
|
||||||
|
|
||||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.95", "", { "os": "win32", "cpu": "x64" }, "sha512-lH2FHO0HSP2xWT+ccoz0BkLYFsMm7e6OYOh63BUHHh5b7ispnzP4aTyxiaLWrfJwdL0M9rp5cLIY32bhBKF2oA=="],
|
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.93", "", { "os": "win32", "cpu": "x64" }, "sha512-Spllte2W7q+WfB1zVHgHilVJNp+jpp77PkkxTWyMQNvT7vJNt9LABMNjGTGiJBBMkAuKvO0GgFNKxrda7tFKrQ=="],
|
||||||
|
|
||||||
"@opentui/solid": ["@opentui/solid@0.1.95", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.95", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-iotYCvULgDurLXv3vgOzTLnEOySHFOa/6cEDex76jBt+gkniOEh2cjxxIVt6lkfTsk6UNTk6yCdwNK3nca/j+Q=="],
|
"@opentui/solid": ["@opentui/solid@0.1.93", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.93", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-Qx+4qoLSjnRGoo/YY4sZJMyXj09Y5kaAMpVO+65Ax58MMj4TjABN4bOOiRT2KV7sKOMTjxiAgXAIaBuqBBJ0Qg=="],
|
||||||
|
|
||||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||||
|
|
||||||
@@ -2771,7 +2771,7 @@
|
|||||||
|
|
||||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||||
|
|
||||||
"effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="],
|
"effect": ["effect@4.0.0-beta.42", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-c1UrRP+tLzyHb4Fepl8XBDJlLQLkrcMXrRBba441GQRxMbeQ/aIOSFcBwSda1iMJ5l9F0lYc3Bhe33/whrmavQ=="],
|
||||||
|
|
||||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-bjfe8/aD0hvUQQEfaNdmKV/Y3dzpf8oz1OUJdgf61WI=",
|
"x86_64-linux": "sha256-UuVbB5lTRB4bIcaKMc8CLSbQW7m9EjXgxYvxp/uO7Co=",
|
||||||
"aarch64-linux": "sha256-iU9v+ekSCB/qTUG+pOOpSMhPh+0hWnWU5jzDNllEkxU=",
|
"aarch64-linux": "sha256-8D7ReLRVb7NDd5PQTVxFhRLmlLbfjK007XgIhhpNKoE=",
|
||||||
"aarch64-darwin": "sha256-SgNydQLeAjbX0J49f2VKcgKg2Y30pK826R2qQJBMWE4=",
|
"aarch64-darwin": "sha256-M+z7C/eXfVqwDiGiiwKo/LT/m4dvCjL1Pblsr1kxoyI=",
|
||||||
"x86_64-darwin": "sha256-/rzwNuI9x55qi0UcU7QvPUTupErmkt62T09g1omXkQk="
|
"x86_64-darwin": "sha256-RzZS6GMwYVDPK0W+K/mlebixNMs2+JRkMG9n8OFhd0c="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -25,7 +25,7 @@
|
|||||||
"packages/slack"
|
"packages/slack"
|
||||||
],
|
],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@effect/platform-node": "4.0.0-beta.43",
|
"@effect/platform-node": "4.0.0-beta.42",
|
||||||
"@types/bun": "1.3.11",
|
"@types/bun": "1.3.11",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||||
"effect": "4.0.0-beta.43",
|
"effect": "4.0.0-beta.42",
|
||||||
"ai": "6.0.138",
|
"ai": "6.0.138",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
|
|||||||
+19
-33
@@ -312,11 +312,10 @@ export async function openSettings(page: Page) {
|
|||||||
return dialog
|
return dialog
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedProjects(page: Page, input: { directory: string; extra?: string[]; serverUrl?: string }) {
|
export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
(args: { directory: string; serverUrl: string; extra: string[] }) => {
|
(args: { directory: string; serverUrl: string; extra: string[] }) => {
|
||||||
const key = "opencode.global.dat:server"
|
const key = "opencode.global.dat:server"
|
||||||
const defaultKey = "opencode.settings.dat:defaultServerUrl"
|
|
||||||
const raw = localStorage.getItem(key)
|
const raw = localStorage.getItem(key)
|
||||||
const parsed = (() => {
|
const parsed = (() => {
|
||||||
if (!raw) return undefined
|
if (!raw) return undefined
|
||||||
@@ -332,7 +331,6 @@ export async function seedProjects(page: Page, input: { directory: string; extra
|
|||||||
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
||||||
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
||||||
const nextProjects = { ...(projects as Record<string, unknown>) }
|
const nextProjects = { ...(projects as Record<string, unknown>) }
|
||||||
const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
|
|
||||||
|
|
||||||
const add = (origin: string, directory: string) => {
|
const add = (origin: string, directory: string) => {
|
||||||
const current = nextProjects[origin]
|
const current = nextProjects[origin]
|
||||||
@@ -358,18 +356,17 @@ export async function seedProjects(page: Page, input: { directory: string; extra
|
|||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
key,
|
key,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
list: nextList,
|
list,
|
||||||
projects: nextProjects,
|
projects: nextProjects,
|
||||||
lastProject,
|
lastProject,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
localStorage.setItem(defaultKey, args.serverUrl)
|
|
||||||
},
|
},
|
||||||
{ directory: input.directory, serverUrl: input.serverUrl ?? serverUrl, extra: input.extra ?? [] },
|
{ directory: input.directory, serverUrl, extra: input.extra ?? [] },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTestProject(input?: { serverUrl?: string }) {
|
export async function createTestProject() {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
||||||
const id = `e2e-${path.basename(root)}`
|
const id = `e2e-${path.basename(root)}`
|
||||||
|
|
||||||
@@ -384,7 +381,7 @@ export async function createTestProject(input?: { serverUrl?: string }) {
|
|||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
})
|
})
|
||||||
|
|
||||||
return resolveDirectory(root, input?.serverUrl)
|
return resolveDirectory(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanupTestProject(directory: string) {
|
export async function cleanupTestProject(directory: string) {
|
||||||
@@ -433,22 +430,22 @@ export async function waitSlug(page: Page, skip: string[] = []) {
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveSlug(slug: string, input?: { serverUrl?: string }) {
|
export async function resolveSlug(slug: string) {
|
||||||
const directory = base64Decode(slug)
|
const directory = base64Decode(slug)
|
||||||
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
||||||
const resolved = await resolveDirectory(directory, input?.serverUrl)
|
const resolved = await resolveDirectory(directory)
|
||||||
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
|
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitDir(page: Page, directory: string, input?: { serverUrl?: string }) {
|
export async function waitDir(page: Page, directory: string) {
|
||||||
const target = await resolveDirectory(directory, input?.serverUrl)
|
const target = await resolveDirectory(directory)
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
await assertHealthy(page, "waitDir")
|
await assertHealthy(page, "waitDir")
|
||||||
const slug = slugFromUrl(page.url())
|
const slug = slugFromUrl(page.url())
|
||||||
if (!slug) return ""
|
if (!slug) return ""
|
||||||
return resolveSlug(slug, input)
|
return resolveSlug(slug)
|
||||||
.then((item) => item.directory)
|
.then((item) => item.directory)
|
||||||
.catch(() => "")
|
.catch(() => "")
|
||||||
},
|
},
|
||||||
@@ -458,15 +455,15 @@ export async function waitDir(page: Page, directory: string, input?: { serverUrl
|
|||||||
return { directory: target, slug: base64Encode(target) }
|
return { directory: target, slug: base64Encode(target) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitSession(page: Page, input: { directory: string; sessionID?: string; serverUrl?: string }) {
|
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
|
||||||
const target = await resolveDirectory(input.directory, input.serverUrl)
|
const target = await resolveDirectory(input.directory)
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
await assertHealthy(page, "waitSession")
|
await assertHealthy(page, "waitSession")
|
||||||
const slug = slugFromUrl(page.url())
|
const slug = slugFromUrl(page.url())
|
||||||
if (!slug) return false
|
if (!slug) return false
|
||||||
const resolved = await resolveSlug(slug, { serverUrl: input.serverUrl }).catch(() => undefined)
|
const resolved = await resolveSlug(slug).catch(() => undefined)
|
||||||
if (!resolved || resolved.directory !== target) return false
|
if (!resolved || resolved.directory !== target) return false
|
||||||
const current = sessionIDFromUrl(page.url())
|
const current = sessionIDFromUrl(page.url())
|
||||||
if (input.sessionID && current !== input.sessionID) return false
|
if (input.sessionID && current !== input.sessionID) return false
|
||||||
@@ -476,7 +473,7 @@ export async function waitSession(page: Page, input: { directory: string; sessio
|
|||||||
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
||||||
if (!input.sessionID && state?.sessionID) return false
|
if (!input.sessionID && state?.sessionID) return false
|
||||||
if (state?.dir) {
|
if (state?.dir) {
|
||||||
const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
|
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
|
||||||
if (dir !== target) return false
|
if (dir !== target) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,9 +489,9 @@ export async function waitSession(page: Page, input: { directory: string; sessio
|
|||||||
return { directory: target, slug: base64Encode(target) }
|
return { directory: target, slug: base64Encode(target) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000, serverUrl?: string) {
|
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
|
||||||
const sdk = createSdk(directory, serverUrl)
|
const sdk = createSdk(directory)
|
||||||
const target = await resolveDirectory(directory, serverUrl)
|
const target = await resolveDirectory(directory)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
@@ -504,7 +501,7 @@ export async function waitSessionSaved(directory: string, sessionID: string, tim
|
|||||||
.then((x) => x.data)
|
.then((x) => x.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
if (!data?.directory) return ""
|
if (!data?.directory) return ""
|
||||||
return resolveDirectory(data.directory, serverUrl).catch(() => data.directory)
|
return resolveDirectory(data.directory).catch(() => data.directory)
|
||||||
},
|
},
|
||||||
{ timeout },
|
{ timeout },
|
||||||
)
|
)
|
||||||
@@ -669,9 +666,8 @@ export async function cleanupSession(input: {
|
|||||||
sessionID: string
|
sessionID: string
|
||||||
directory?: string
|
directory?: string
|
||||||
sdk?: ReturnType<typeof createSdk>
|
sdk?: ReturnType<typeof createSdk>
|
||||||
serverUrl?: string
|
|
||||||
}) {
|
}) {
|
||||||
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory, input.serverUrl) : undefined)
|
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
|
||||||
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
|
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
|
||||||
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
|
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
|
||||||
const current = await status(sdk, input.sessionID).catch(() => undefined)
|
const current = await status(sdk, input.sessionID).catch(() => undefined)
|
||||||
@@ -1023,13 +1019,3 @@ export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
|
|||||||
await expect(menu).toBeVisible()
|
await expect(menu).toBeVisible()
|
||||||
return menu
|
return menu
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assistantText(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
|
||||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
|
||||||
return messages
|
|
||||||
.filter((m) => m.info.role === "assistant")
|
|
||||||
.flatMap((m) => m.parts)
|
|
||||||
.filter((p) => p.type === "text")
|
|
||||||
.map((p) => p.text)
|
|
||||||
.join("\n")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
import { spawn } from "node:child_process"
|
|
||||||
import fs from "node:fs/promises"
|
|
||||||
import net from "node:net"
|
|
||||||
import os from "node:os"
|
|
||||||
import path from "node:path"
|
|
||||||
import { fileURLToPath } from "node:url"
|
|
||||||
|
|
||||||
type Handle = {
|
|
||||||
url: string
|
|
||||||
stop: () => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
function freePort() {
|
|
||||||
return new Promise<number>((resolve, reject) => {
|
|
||||||
const server = net.createServer()
|
|
||||||
server.once("error", reject)
|
|
||||||
server.listen(0, () => {
|
|
||||||
const address = server.address()
|
|
||||||
if (!address || typeof address === "string") {
|
|
||||||
server.close(() => reject(new Error("Failed to acquire a free port")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
server.close((err) => {
|
|
||||||
if (err) reject(err)
|
|
||||||
else resolve(address.port)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForHealth(url: string, probe = "/global/health") {
|
|
||||||
const end = Date.now() + 120_000
|
|
||||||
let last = ""
|
|
||||||
while (Date.now() < end) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${url}${probe}`)
|
|
||||||
if (res.ok) return
|
|
||||||
last = `status ${res.status}`
|
|
||||||
} catch (err) {
|
|
||||||
last = err instanceof Error ? err.message : String(err)
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
||||||
}
|
|
||||||
throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const LOG_CAP = 100
|
|
||||||
|
|
||||||
function cap(input: string[]) {
|
|
||||||
if (input.length > LOG_CAP) input.splice(0, input.length - LOG_CAP)
|
|
||||||
}
|
|
||||||
|
|
||||||
function tail(input: string[]) {
|
|
||||||
return input.slice(-40).join("")
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startBackend(label: string): Promise<Handle> {
|
|
||||||
const port = await freePort()
|
|
||||||
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
|
|
||||||
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
||||||
const repoDir = path.resolve(appDir, "../..")
|
|
||||||
const opencodeDir = path.join(repoDir, "packages", "opencode")
|
|
||||||
const env = {
|
|
||||||
...process.env,
|
|
||||||
OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true",
|
|
||||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
|
||||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
|
||||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
|
|
||||||
OPENCODE_TEST_HOME: path.join(sandbox, "home"),
|
|
||||||
XDG_DATA_HOME: path.join(sandbox, "share"),
|
|
||||||
XDG_CACHE_HOME: path.join(sandbox, "cache"),
|
|
||||||
XDG_CONFIG_HOME: path.join(sandbox, "config"),
|
|
||||||
XDG_STATE_HOME: path.join(sandbox, "state"),
|
|
||||||
OPENCODE_CLIENT: "app",
|
|
||||||
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
|
||||||
} satisfies Record<string, string | undefined>
|
|
||||||
const out: string[] = []
|
|
||||||
const err: string[] = []
|
|
||||||
const proc = spawn(
|
|
||||||
"bun",
|
|
||||||
["run", "--conditions=browser", "./src/index.ts", "serve", "--port", String(port), "--hostname", "127.0.0.1"],
|
|
||||||
{
|
|
||||||
cwd: opencodeDir,
|
|
||||||
env,
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
proc.stdout?.on("data", (chunk) => {
|
|
||||||
out.push(String(chunk))
|
|
||||||
cap(out)
|
|
||||||
})
|
|
||||||
proc.stderr?.on("data", (chunk) => {
|
|
||||||
err.push(String(chunk))
|
|
||||||
cap(err)
|
|
||||||
})
|
|
||||||
|
|
||||||
const url = `http://127.0.0.1:${port}`
|
|
||||||
try {
|
|
||||||
await waitForHealth(url)
|
|
||||||
} catch (error) {
|
|
||||||
proc.kill("SIGTERM")
|
|
||||||
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
|
|
||||||
throw new Error(
|
|
||||||
[
|
|
||||||
`Failed to start isolated e2e backend for ${label}`,
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
tail(out),
|
|
||||||
tail(err),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
async stop() {
|
|
||||||
if (proc.exitCode === null) {
|
|
||||||
proc.kill("SIGTERM")
|
|
||||||
await new Promise((resolve) => proc.once("exit", () => resolve(undefined))).catch(() => undefined)
|
|
||||||
}
|
|
||||||
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+47
-169
@@ -1,9 +1,5 @@
|
|||||||
import { test as base, expect, type Page } from "@playwright/test"
|
import { test as base, expect, type Page } from "@playwright/test"
|
||||||
import { ManagedRuntime } from "effect"
|
|
||||||
import type { E2EWindow } from "../src/testing/terminal"
|
import type { E2EWindow } from "../src/testing/terminal"
|
||||||
import type { Item, Reply, Usage } from "../../opencode/test/lib/llm-server"
|
|
||||||
import { TestLLMServer } from "../../opencode/test/lib/llm-server"
|
|
||||||
import { startBackend } from "./backend"
|
|
||||||
import {
|
import {
|
||||||
healthPhase,
|
healthPhase,
|
||||||
cleanupSession,
|
cleanupSession,
|
||||||
@@ -17,38 +13,6 @@ import {
|
|||||||
} from "./actions"
|
} from "./actions"
|
||||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||||
|
|
||||||
type LLMFixture = {
|
|
||||||
url: string
|
|
||||||
push: (...input: (Item | Reply)[]) => Promise<void>
|
|
||||||
pushMatch: (
|
|
||||||
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
|
||||||
...input: (Item | Reply)[]
|
|
||||||
) => Promise<void>
|
|
||||||
textMatch: (
|
|
||||||
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
|
||||||
value: string,
|
|
||||||
opts?: { usage?: Usage },
|
|
||||||
) => Promise<void>
|
|
||||||
toolMatch: (
|
|
||||||
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
|
||||||
name: string,
|
|
||||||
input: unknown,
|
|
||||||
) => Promise<void>
|
|
||||||
text: (value: string, opts?: { usage?: Usage }) => Promise<void>
|
|
||||||
tool: (name: string, input: unknown) => Promise<void>
|
|
||||||
toolHang: (name: string, input: unknown) => Promise<void>
|
|
||||||
reason: (value: string, opts?: { text?: string; usage?: Usage }) => Promise<void>
|
|
||||||
fail: (message?: unknown) => Promise<void>
|
|
||||||
error: (status: number, body: unknown) => Promise<void>
|
|
||||||
hang: () => Promise<void>
|
|
||||||
hold: (value: string, wait: PromiseLike<unknown>) => Promise<void>
|
|
||||||
hits: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
|
|
||||||
calls: () => Promise<number>
|
|
||||||
wait: (count: number) => Promise<void>
|
|
||||||
inputs: () => Promise<Record<string, unknown>[]>
|
|
||||||
pending: () => Promise<number>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const settingsKey = "settings.v3"
|
export const settingsKey = "settings.v3"
|
||||||
|
|
||||||
const seedModel = (() => {
|
const seedModel = (() => {
|
||||||
@@ -61,82 +25,27 @@ const seedModel = (() => {
|
|||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
type ProjectHandle = {
|
|
||||||
directory: string
|
|
||||||
slug: string
|
|
||||||
gotoSession: (sessionID?: string) => Promise<void>
|
|
||||||
trackSession: (sessionID: string, directory?: string) => void
|
|
||||||
trackDirectory: (directory: string) => void
|
|
||||||
sdk: ReturnType<typeof createSdk>
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProjectOptions = {
|
|
||||||
extra?: string[]
|
|
||||||
model?: { providerID: string; modelID: string }
|
|
||||||
setup?: (directory: string) => Promise<void>
|
|
||||||
beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
type TestFixtures = {
|
type TestFixtures = {
|
||||||
llm: LLMFixture
|
|
||||||
sdk: ReturnType<typeof createSdk>
|
sdk: ReturnType<typeof createSdk>
|
||||||
gotoSession: (sessionID?: string) => Promise<void>
|
gotoSession: (sessionID?: string) => Promise<void>
|
||||||
withProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
|
withProject: <T>(
|
||||||
withBackendProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
|
callback: (project: {
|
||||||
|
directory: string
|
||||||
|
slug: string
|
||||||
|
gotoSession: (sessionID?: string) => Promise<void>
|
||||||
|
trackSession: (sessionID: string, directory?: string) => void
|
||||||
|
trackDirectory: (directory: string) => void
|
||||||
|
}) => Promise<T>,
|
||||||
|
options?: { extra?: string[] },
|
||||||
|
) => Promise<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkerFixtures = {
|
type WorkerFixtures = {
|
||||||
backend: {
|
|
||||||
url: string
|
|
||||||
sdk: (directory?: string) => ReturnType<typeof createSdk>
|
|
||||||
}
|
|
||||||
directory: string
|
directory: string
|
||||||
slug: string
|
slug: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||||
backend: [
|
|
||||||
async ({}, use, workerInfo) => {
|
|
||||||
const handle = await startBackend(`w${workerInfo.workerIndex}`)
|
|
||||||
try {
|
|
||||||
await use({
|
|
||||||
url: handle.url,
|
|
||||||
sdk: (directory?: string) => createSdk(directory, handle.url),
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
await handle.stop()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ scope: "worker" },
|
|
||||||
],
|
|
||||||
llm: async ({}, use) => {
|
|
||||||
const rt = ManagedRuntime.make(TestLLMServer.layer)
|
|
||||||
try {
|
|
||||||
const svc = await rt.runPromise(TestLLMServer.asEffect())
|
|
||||||
await use({
|
|
||||||
url: svc.url,
|
|
||||||
push: (...input) => rt.runPromise(svc.push(...input)),
|
|
||||||
pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)),
|
|
||||||
textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)),
|
|
||||||
toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)),
|
|
||||||
text: (value, opts) => rt.runPromise(svc.text(value, opts)),
|
|
||||||
tool: (name, input) => rt.runPromise(svc.tool(name, input)),
|
|
||||||
toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
|
|
||||||
reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
|
|
||||||
fail: (message) => rt.runPromise(svc.fail(message)),
|
|
||||||
error: (status, body) => rt.runPromise(svc.error(status, body)),
|
|
||||||
hang: () => rt.runPromise(svc.hang),
|
|
||||||
hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
|
|
||||||
hits: () => rt.runPromise(svc.hits),
|
|
||||||
calls: () => rt.runPromise(svc.calls),
|
|
||||||
wait: (count) => rt.runPromise(svc.wait(count)),
|
|
||||||
inputs: () => rt.runPromise(svc.inputs),
|
|
||||||
pending: () => rt.runPromise(svc.pending),
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
await rt.dispose()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
page: async ({ page }, use) => {
|
page: async ({ page }, use) => {
|
||||||
let boundary: string | undefined
|
let boundary: string | undefined
|
||||||
setHealthPhase(page, "test")
|
setHealthPhase(page, "test")
|
||||||
@@ -186,76 +95,45 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
await use(gotoSession)
|
await use(gotoSession)
|
||||||
},
|
},
|
||||||
withProject: async ({ page }, use) => {
|
withProject: async ({ page }, use) => {
|
||||||
await use((callback, options) => runProject(page, callback, options))
|
await use(async (callback, options) => {
|
||||||
},
|
const root = await createTestProject()
|
||||||
withBackendProject: async ({ page, backend }, use) => {
|
const sessions = new Map<string, string>()
|
||||||
await use((callback, options) =>
|
const dirs = new Set<string>()
|
||||||
runProject(page, callback, { ...options, serverUrl: backend.url, sdk: backend.sdk }),
|
await seedStorage(page, { directory: root, extra: options?.extra })
|
||||||
)
|
|
||||||
|
const gotoSession = async (sessionID?: string) => {
|
||||||
|
await page.goto(sessionPath(root, sessionID))
|
||||||
|
await waitSession(page, { directory: root, sessionID })
|
||||||
|
const current = sessionIDFromUrl(page.url())
|
||||||
|
if (current) trackSession(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackSession = (sessionID: string, directory?: string) => {
|
||||||
|
sessions.set(sessionID, directory ?? root)
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackDirectory = (directory: string) => {
|
||||||
|
if (directory !== root) dirs.add(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoSession()
|
||||||
|
const slug = await waitSlug(page)
|
||||||
|
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
||||||
|
} finally {
|
||||||
|
setHealthPhase(page, "cleanup")
|
||||||
|
await Promise.allSettled(
|
||||||
|
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
||||||
|
)
|
||||||
|
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
||||||
|
await cleanupTestProject(root)
|
||||||
|
setHealthPhase(page, "test")
|
||||||
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
async function runProject<T>(
|
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
|
||||||
page: Page,
|
|
||||||
callback: (project: ProjectHandle) => Promise<T>,
|
|
||||||
options?: ProjectOptions & {
|
|
||||||
serverUrl?: string
|
|
||||||
sdk?: (directory?: string) => ReturnType<typeof createSdk>
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const url = options?.serverUrl
|
|
||||||
const root = await createTestProject(url ? { serverUrl: url } : undefined)
|
|
||||||
const sdk = options?.sdk?.(root) ?? createSdk(root, url)
|
|
||||||
const sessions = new Map<string, string>()
|
|
||||||
const dirs = new Set<string>()
|
|
||||||
await options?.setup?.(root)
|
|
||||||
await seedStorage(page, {
|
|
||||||
directory: root,
|
|
||||||
extra: options?.extra,
|
|
||||||
model: options?.model,
|
|
||||||
serverUrl: url,
|
|
||||||
})
|
|
||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
|
||||||
await page.goto(sessionPath(root, sessionID))
|
|
||||||
await waitSession(page, { directory: root, sessionID, serverUrl: url })
|
|
||||||
const current = sessionIDFromUrl(page.url())
|
|
||||||
if (current) trackSession(current)
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackSession = (sessionID: string, directory?: string) => {
|
|
||||||
sessions.set(sessionID, directory ?? root)
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackDirectory = (directory: string) => {
|
|
||||||
if (directory !== root) dirs.add(directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await options?.beforeGoto?.({ directory: root, sdk })
|
|
||||||
await gotoSession()
|
|
||||||
const slug = await waitSlug(page)
|
|
||||||
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory, sdk })
|
|
||||||
} finally {
|
|
||||||
setHealthPhase(page, "cleanup")
|
|
||||||
await Promise.allSettled(
|
|
||||||
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory, serverUrl: url })),
|
|
||||||
)
|
|
||||||
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
|
||||||
await cleanupTestProject(root)
|
|
||||||
setHealthPhase(page, "test")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedStorage(
|
|
||||||
page: Page,
|
|
||||||
input: {
|
|
||||||
directory: string
|
|
||||||
extra?: string[]
|
|
||||||
model?: { providerID: string; modelID: string }
|
|
||||||
serverUrl?: string
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
await seedProjects(page, input)
|
await seedProjects(page, input)
|
||||||
await page.addInitScript((model: { providerID: string; modelID: string }) => {
|
await page.addInitScript((model: { providerID: string; modelID: string }) => {
|
||||||
const win = window as E2EWindow
|
const win = window as E2EWindow
|
||||||
@@ -280,7 +158,7 @@ async function seedStorage(
|
|||||||
variant: {},
|
variant: {},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}, input.model ?? seedModel)
|
}, seedModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { expect }
|
export { expect }
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { createSdk } from "../utils"
|
|
||||||
|
|
||||||
export const openaiModel = { providerID: "openai", modelID: "gpt-5.3-chat-latest" }
|
|
||||||
|
|
||||||
type Hit = { body: Record<string, unknown> }
|
|
||||||
|
|
||||||
export function bodyText(hit: Hit) {
|
|
||||||
return JSON.stringify(hit.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function titleMatch(hit: Hit) {
|
|
||||||
return bodyText(hit).includes("Generate a title for this conversation")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function promptMatch(token: string) {
|
|
||||||
return (hit: Hit) => bodyText(hit).includes(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function withMockOpenAI<T>(input: { serverUrl: string; llmUrl: string; fn: () => Promise<T> }) {
|
|
||||||
const sdk = createSdk(undefined, input.serverUrl)
|
|
||||||
const prev = await sdk.global.config.get().then((res) => res.data ?? {})
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sdk.global.config.update({
|
|
||||||
config: {
|
|
||||||
...prev,
|
|
||||||
model: `${openaiModel.providerID}/${openaiModel.modelID}`,
|
|
||||||
enabled_providers: ["openai"],
|
|
||||||
provider: {
|
|
||||||
...prev.provider,
|
|
||||||
openai: {
|
|
||||||
...prev.provider?.openai,
|
|
||||||
options: {
|
|
||||||
...prev.provider?.openai?.options,
|
|
||||||
apiKey: "test-key",
|
|
||||||
baseURL: input.llmUrl,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return await input.fn()
|
|
||||||
} finally {
|
|
||||||
await sdk.global.config.update({ config: prev })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +1,47 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { assistantText, sessionIDFromUrl, withSession } from "../actions"
|
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
||||||
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
|
|
||||||
|
|
||||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
|
|
||||||
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
||||||
// the connection open while the agent works, causing "Failed to fetch" over
|
// the connection open while the agent works, causing "Failed to fetch" over
|
||||||
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
|
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
|
||||||
test("prompt succeeds when sync message endpoint is unreachable", async ({
|
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => {
|
||||||
page,
|
|
||||||
llm,
|
|
||||||
backend,
|
|
||||||
withBackendProject,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
// Simulate Tailscale/VPN killing the long-lived sync connection
|
// Simulate Tailscale/VPN killing the long-lived sync connection
|
||||||
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
|
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
|
||||||
|
|
||||||
await withMockOpenAI({
|
await gotoSession()
|
||||||
serverUrl: backend.url,
|
|
||||||
llmUrl: llm.url,
|
|
||||||
fn: async () => {
|
|
||||||
const token = `E2E_ASYNC_${Date.now()}`
|
|
||||||
await llm.textMatch(titleMatch, "E2E Title")
|
|
||||||
await llm.textMatch(promptMatch(token), token)
|
|
||||||
|
|
||||||
await withBackendProject(
|
const token = `E2E_ASYNC_${Date.now()}`
|
||||||
async (project) => {
|
await page.locator(promptSelector).click()
|
||||||
await page.locator(promptSelector).click()
|
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
await page.keyboard.press("Enter")
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||||
const sessionID = sessionIDFromUrl(page.url())!
|
const sessionID = sessionIDFromUrl(page.url())!
|
||||||
project.trackSession(sessionID)
|
|
||||||
|
|
||||||
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
|
try {
|
||||||
|
// Agent response arrives via SSE despite sync endpoint being dead
|
||||||
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
|
await expect
|
||||||
},
|
.poll(
|
||||||
{
|
async () => {
|
||||||
model: openaiModel,
|
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||||
|
return messages
|
||||||
|
.filter((m) => m.info.role === "assistant")
|
||||||
|
.flatMap((m) => m.parts)
|
||||||
|
.filter((p) => p.type === "text")
|
||||||
|
.map((p) => p.text)
|
||||||
|
.join("\n")
|
||||||
},
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
)
|
)
|
||||||
},
|
.toContain(token)
|
||||||
})
|
} finally {
|
||||||
|
await cleanupSession({ sdk, sessionID })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { assistantText, sessionIDFromUrl } from "../actions"
|
import { withSession } from "../actions"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
|
|
||||||
|
|
||||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
|
|
||||||
@@ -44,13 +43,20 @@ async function wait(page: Page, value: string) {
|
|||||||
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
|
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reply(
|
async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
|
||||||
sdk: { session: { messages: Parameters<typeof assistantText>[0]["session"] } },
|
|
||||||
sessionID: string,
|
|
||||||
token: string,
|
|
||||||
) {
|
|
||||||
await expect
|
await expect
|
||||||
.poll(() => assistantText(sdk as Parameters<typeof assistantText>[0], sessionID), { timeout: 90_000 })
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||||
|
return messages
|
||||||
|
.filter((item) => item.info.role === "assistant")
|
||||||
|
.flatMap((item) => item.parts)
|
||||||
|
.filter((item) => item.type === "text")
|
||||||
|
.map((item) => item.text)
|
||||||
|
.join("\n")
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
.toContain(token)
|
.toContain(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,145 +79,106 @@ async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string,
|
|||||||
.toContain(token)
|
.toContain(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("prompt history restores unsent draft with arrow navigation", async ({
|
test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
|
||||||
page,
|
|
||||||
llm,
|
|
||||||
backend,
|
|
||||||
withBackendProject,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
await withMockOpenAI({
|
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
|
||||||
serverUrl: backend.url,
|
await gotoSession(session.id)
|
||||||
llmUrl: llm.url,
|
|
||||||
fn: async () => {
|
|
||||||
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
|
||||||
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
|
||||||
const first = `Reply with exactly: ${firstToken}`
|
|
||||||
const second = `Reply with exactly: ${secondToken}`
|
|
||||||
const draft = `draft ${Date.now()}`
|
|
||||||
|
|
||||||
await llm.textMatch(titleMatch, "E2E Title")
|
const prompt = page.locator(promptSelector)
|
||||||
await llm.textMatch(promptMatch(firstToken), firstToken)
|
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
||||||
await llm.textMatch(promptMatch(secondToken), secondToken)
|
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
||||||
|
const first = `Reply with exactly: ${firstToken}`
|
||||||
|
const second = `Reply with exactly: ${secondToken}`
|
||||||
|
const draft = `draft ${Date.now()}`
|
||||||
|
|
||||||
await withBackendProject(
|
await prompt.click()
|
||||||
async (project) => {
|
await page.keyboard.type(first)
|
||||||
const prompt = page.locator(promptSelector)
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await reply(sdk, session.id, firstToken)
|
||||||
|
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
await page.keyboard.type(first)
|
await page.keyboard.type(second)
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
await wait(page, "")
|
await wait(page, "")
|
||||||
|
await reply(sdk, session.id, secondToken)
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
await prompt.click()
|
||||||
const sessionID = sessionIDFromUrl(page.url())!
|
await page.keyboard.type(draft)
|
||||||
project.trackSession(sessionID)
|
await wait(page, draft)
|
||||||
await reply(project.sdk, sessionID, firstToken)
|
|
||||||
|
|
||||||
await prompt.click()
|
// Clear the draft before navigating history (ArrowUp only works when prompt is empty)
|
||||||
await page.keyboard.type(second)
|
await prompt.fill("")
|
||||||
await page.keyboard.press("Enter")
|
await wait(page, "")
|
||||||
await wait(page, "")
|
|
||||||
await reply(project.sdk, sessionID, secondToken)
|
|
||||||
|
|
||||||
await prompt.click()
|
await page.keyboard.press("ArrowUp")
|
||||||
await page.keyboard.type(draft)
|
await wait(page, second)
|
||||||
await wait(page, draft)
|
|
||||||
|
|
||||||
await prompt.fill("")
|
await page.keyboard.press("ArrowUp")
|
||||||
await wait(page, "")
|
await wait(page, first)
|
||||||
|
|
||||||
await page.keyboard.press("ArrowUp")
|
await page.keyboard.press("ArrowDown")
|
||||||
await wait(page, second)
|
await wait(page, second)
|
||||||
|
|
||||||
await page.keyboard.press("ArrowUp")
|
await page.keyboard.press("ArrowDown")
|
||||||
await wait(page, first)
|
await wait(page, "")
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
|
||||||
await wait(page, second)
|
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
|
||||||
await wait(page, "")
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: openaiModel,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("shell history stays separate from normal prompt history", async ({ page, llm, backend, withBackendProject }) => {
|
test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
await withMockOpenAI({
|
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
|
||||||
serverUrl: backend.url,
|
await gotoSession(session.id)
|
||||||
llmUrl: llm.url,
|
|
||||||
fn: async () => {
|
|
||||||
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
|
|
||||||
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
|
|
||||||
const normalToken = `E2E_NORMAL_${Date.now()}`
|
|
||||||
const first = `echo ${firstToken}`
|
|
||||||
const second = `echo ${secondToken}`
|
|
||||||
const normal = `Reply with exactly: ${normalToken}`
|
|
||||||
|
|
||||||
await llm.textMatch(titleMatch, "E2E Title")
|
const prompt = page.locator(promptSelector)
|
||||||
await llm.textMatch(promptMatch(normalToken), normalToken)
|
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
|
||||||
|
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
|
||||||
|
const normalToken = `E2E_NORMAL_${Date.now()}`
|
||||||
|
const first = `echo ${firstToken}`
|
||||||
|
const second = `echo ${secondToken}`
|
||||||
|
const normal = `Reply with exactly: ${normalToken}`
|
||||||
|
|
||||||
await withBackendProject(
|
await prompt.click()
|
||||||
async (project) => {
|
await page.keyboard.type("!")
|
||||||
const prompt = page.locator(promptSelector)
|
await page.keyboard.type(first)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await shell(sdk, session.id, first, firstToken)
|
||||||
|
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
await page.keyboard.type("!")
|
await page.keyboard.type("!")
|
||||||
await page.keyboard.type(first)
|
await page.keyboard.type(second)
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
await wait(page, "")
|
await wait(page, "")
|
||||||
|
await shell(sdk, session.id, second, secondToken)
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
await prompt.click()
|
||||||
const sessionID = sessionIDFromUrl(page.url())!
|
await page.keyboard.type("!")
|
||||||
project.trackSession(sessionID)
|
await page.keyboard.press("ArrowUp")
|
||||||
await shell(project.sdk, sessionID, first, firstToken)
|
await wait(page, second)
|
||||||
|
|
||||||
await prompt.click()
|
await page.keyboard.press("ArrowUp")
|
||||||
await page.keyboard.type("!")
|
await wait(page, first)
|
||||||
await page.keyboard.type(second)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
await wait(page, "")
|
|
||||||
await shell(project.sdk, sessionID, second, secondToken)
|
|
||||||
|
|
||||||
await prompt.click()
|
await page.keyboard.press("ArrowDown")
|
||||||
await page.keyboard.type("!")
|
await wait(page, second)
|
||||||
await page.keyboard.press("ArrowUp")
|
|
||||||
await wait(page, second)
|
|
||||||
|
|
||||||
await page.keyboard.press("ArrowUp")
|
await page.keyboard.press("ArrowDown")
|
||||||
await wait(page, first)
|
await wait(page, "")
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
await page.keyboard.press("Escape")
|
||||||
await wait(page, second)
|
await wait(page, "")
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
await prompt.click()
|
||||||
await wait(page, "")
|
await page.keyboard.type(normal)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await reply(sdk, session.id, normalToken)
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
await prompt.click()
|
||||||
await wait(page, "")
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, normal)
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.type(normal)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
await wait(page, "")
|
|
||||||
await reply(project.sdk, sessionID, normalToken)
|
|
||||||
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.press("ArrowUp")
|
|
||||||
await wait(page, normal)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: openaiModel,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { sessionIDFromUrl } from "../actions"
|
import { sessionIDFromUrl } from "../actions"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
|
import { createSdk } from "../utils"
|
||||||
|
|
||||||
const isBash = (part: unknown): part is ToolPart => {
|
const isBash = (part: unknown): part is ToolPart => {
|
||||||
if (!part || typeof part !== "object") return false
|
if (!part || typeof part !== "object") return false
|
||||||
@@ -10,12 +11,13 @@ const isBash = (part: unknown): part is ToolPart => {
|
|||||||
return "state" in part
|
return "state" in part
|
||||||
}
|
}
|
||||||
|
|
||||||
test("shell mode runs a command in the project directory", async ({ page, withBackendProject }) => {
|
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
await withBackendProject(async ({ directory, gotoSession, trackSession, sdk }) => {
|
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
||||||
|
const sdk = createSdk(directory)
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
const cmd = process.platform === "win32" ? "dir" : "command ls"
|
const cmd = process.platform === "win32" ? "dir" : "ls"
|
||||||
|
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
|
|||||||
@@ -22,45 +22,43 @@ async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
|
|||||||
.toBeGreaterThan(0)
|
.toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("/share and /unshare update session share state", async ({ page, withBackendProject }) => {
|
test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
|
||||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
||||||
await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
const prompt = page.locator(promptSelector)
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
|
|
||||||
await seed(project.sdk, session.id)
|
await seed(sdk, session.id)
|
||||||
await project.gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
await page.keyboard.type("/share")
|
await page.keyboard.type("/share")
|
||||||
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
|
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.share?.url || undefined
|
return data?.share?.url || undefined
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
)
|
)
|
||||||
.not.toBeUndefined()
|
.not.toBeUndefined()
|
||||||
|
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
await page.keyboard.type("/unshare")
|
await page.keyboard.type("/unshare")
|
||||||
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
|
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.share?.url || undefined
|
return data?.share?.url || undefined
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
)
|
)
|
||||||
.toBeUndefined()
|
.toBeUndefined()
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { assistantText, sessionIDFromUrl } from "../actions"
|
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
||||||
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
|
|
||||||
|
|
||||||
test("can send a prompt and receive a reply", async ({ page, llm, backend, withBackendProject }) => {
|
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const pageErrors: string[] = []
|
const pageErrors: string[] = []
|
||||||
@@ -12,44 +11,42 @@ test("can send a prompt and receive a reply", async ({ page, llm, backend, withB
|
|||||||
}
|
}
|
||||||
page.on("pageerror", onPageError)
|
page.on("pageerror", onPageError)
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const token = `E2E_OK_${Date.now()}`
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||||
|
|
||||||
|
const sessionID = (() => {
|
||||||
|
const id = sessionIDFromUrl(page.url())
|
||||||
|
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||||
|
return id
|
||||||
|
})()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withMockOpenAI({
|
await expect
|
||||||
serverUrl: backend.url,
|
.poll(
|
||||||
llmUrl: llm.url,
|
async () => {
|
||||||
fn: async () => {
|
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||||
const token = `E2E_OK_${Date.now()}`
|
return messages
|
||||||
|
.filter((m) => m.info.role === "assistant")
|
||||||
|
.flatMap((m) => m.parts)
|
||||||
|
.filter((p) => p.type === "text")
|
||||||
|
.map((p) => p.text)
|
||||||
|
.join("\n")
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
|
||||||
await llm.textMatch(titleMatch, "E2E Title")
|
.toContain(token)
|
||||||
await llm.textMatch(promptMatch(token), token)
|
|
||||||
|
|
||||||
await withBackendProject(
|
|
||||||
async (project) => {
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
|
||||||
|
|
||||||
const sessionID = (() => {
|
|
||||||
const id = sessionIDFromUrl(page.url())
|
|
||||||
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
|
||||||
return id
|
|
||||||
})()
|
|
||||||
project.trackSession(sessionID)
|
|
||||||
|
|
||||||
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
|
|
||||||
|
|
||||||
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: openaiModel,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
page.off("pageerror", onPageError)
|
page.off("pageerror", onPageError)
|
||||||
|
await cleanupSession({ sdk, sessionID })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageErrors.length > 0) {
|
if (pageErrors.length > 0) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { seedSessionTask, withSession } from "../actions"
|
import { seedSessionTask, withSession } from "../actions"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
|
|
||||||
test("task tool child-session link does not trigger stale show errors", async ({ page, withBackendProject }) => {
|
test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const errs: string[] = []
|
const errs: string[] = []
|
||||||
@@ -10,32 +10,28 @@ test("task tool child-session link does not trigger stale show errors", async ({
|
|||||||
}
|
}
|
||||||
page.on("pageerror", onError)
|
page.on("pageerror", onError)
|
||||||
|
|
||||||
await withBackendProject(async ({ gotoSession, trackSession, sdk }) => {
|
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
|
||||||
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
|
const child = await seedSessionTask(sdk, {
|
||||||
trackSession(session.id)
|
sessionID: session.id,
|
||||||
const child = await seedSessionTask(sdk, {
|
description: "Open child session",
|
||||||
sessionID: session.id,
|
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
|
||||||
description: "Open child session",
|
|
||||||
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
|
|
||||||
})
|
|
||||||
trackSession(child.sessionID)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoSession(session.id)
|
|
||||||
|
|
||||||
const link = page
|
|
||||||
.locator("a.subagent-link")
|
|
||||||
.filter({ hasText: /open child session/i })
|
|
||||||
.first()
|
|
||||||
await expect(link).toBeVisible({ timeout: 30_000 })
|
|
||||||
await link.click()
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
|
|
||||||
await page.waitForTimeout(1000)
|
|
||||||
expect(errs).toEqual([])
|
|
||||||
} finally {
|
|
||||||
page.off("pageerror", onError)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
const link = page
|
||||||
|
.locator("a.subagent-link")
|
||||||
|
.filter({ hasText: /open child session/i })
|
||||||
|
.first()
|
||||||
|
await expect(link).toBeVisible({ timeout: 30_000 })
|
||||||
|
await link.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
expect(errs).toEqual([])
|
||||||
|
} finally {
|
||||||
|
page.off("pageerror", onError)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
sessionComposerDockSelector,
|
sessionComposerDockSelector,
|
||||||
sessionTodoToggleButtonSelector,
|
sessionTodoToggleButtonSelector,
|
||||||
} from "../selectors"
|
} from "../selectors"
|
||||||
import { modKey } from "../utils"
|
|
||||||
|
|
||||||
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
||||||
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
|
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
|
||||||
@@ -256,50 +255,168 @@ async function withMockPermission<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("default dock shows prompt input", async ({ page, withBackendProject }) => {
|
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
|
||||||
await withBackendProject(async (project) => {
|
await withDockSession(sdk, "e2e composer dock default", async (session) => {
|
||||||
await withDockSession(project.sdk, "e2e composer dock default", async (session) => {
|
await gotoSession(session.id)
|
||||||
await project.gotoSession(session.id)
|
|
||||||
|
|
||||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
||||||
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
await page.locator(promptSelector).click()
|
||||||
await expect(page.locator(promptSelector)).toBeFocused()
|
await expect(page.locator(promptSelector)).toBeFocused()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const button = page.locator('[data-action="prompt-permissions"]').first()
|
||||||
|
await expect(button).toBeVisible()
|
||||||
|
await expect(button).toHaveAttribute("aria-pressed", "false")
|
||||||
|
|
||||||
|
await setAutoAccept(page, true)
|
||||||
|
await setAutoAccept(page, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock question", async (session) => {
|
||||||
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await seedSessionQuestion(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "Need input",
|
||||||
|
question: "Pick one option",
|
||||||
|
options: [
|
||||||
|
{ label: "Continue", description: "Continue now" },
|
||||||
|
{ label: "Stop", description: "Stop here" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const dock = page.locator(questionDockSelector)
|
||||||
|
await expectQuestionBlocked(page)
|
||||||
|
|
||||||
|
await dock.locator('[data-slot="question-option"]').first().click()
|
||||||
|
await dock.getByRole("button", { name: /submit/i }).click()
|
||||||
|
|
||||||
|
await expectQuestionOpen(page)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("auto-accept toggle works before first submit", async ({ page, withBackendProject }) => {
|
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
|
||||||
await withBackendProject(async ({ gotoSession }) => {
|
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
|
||||||
await gotoSession()
|
await gotoSession(session.id)
|
||||||
|
|
||||||
const button = page.locator('[data-action="prompt-permissions"]').first()
|
|
||||||
await expect(button).toBeVisible()
|
|
||||||
await expect(button).toHaveAttribute("aria-pressed", "false")
|
|
||||||
|
|
||||||
await setAutoAccept(page, true)
|
|
||||||
await setAutoAccept(page, false)
|
await setAutoAccept(page, false)
|
||||||
|
await withMockPermission(
|
||||||
|
page,
|
||||||
|
{
|
||||||
|
id: "per_e2e_once",
|
||||||
|
sessionID: session.id,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["/tmp/opencode-e2e-perm-once"],
|
||||||
|
metadata: { description: "Need permission for command" },
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
async (state) => {
|
||||||
|
await page.goto(page.url())
|
||||||
|
await expectPermissionBlocked(page)
|
||||||
|
|
||||||
|
await clearPermissionDock(page, /allow once/i)
|
||||||
|
await state.resolved()
|
||||||
|
await page.goto(page.url())
|
||||||
|
await expectPermissionOpen(page)
|
||||||
|
},
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("blocked question flow unblocks after submit", async ({ page, withBackendProject }) => {
|
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
|
||||||
await withBackendProject(async (project) => {
|
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
|
||||||
await withDockSession(project.sdk, "e2e composer dock question", async (session) => {
|
await gotoSession(session.id)
|
||||||
await withDockSeed(project.sdk, session.id, async () => {
|
await setAutoAccept(page, false)
|
||||||
await project.gotoSession(session.id)
|
await withMockPermission(
|
||||||
|
page,
|
||||||
|
{
|
||||||
|
id: "per_e2e_reject",
|
||||||
|
sessionID: session.id,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["/tmp/opencode-e2e-perm-reject"],
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
async (state) => {
|
||||||
|
await page.goto(page.url())
|
||||||
|
await expectPermissionBlocked(page)
|
||||||
|
|
||||||
await seedSessionQuestion(project.sdk, {
|
await clearPermissionDock(page, /deny/i)
|
||||||
sessionID: session.id,
|
await state.resolved()
|
||||||
|
await page.goto(page.url())
|
||||||
|
await expectPermissionOpen(page)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
await setAutoAccept(page, false)
|
||||||
|
await withMockPermission(
|
||||||
|
page,
|
||||||
|
{
|
||||||
|
id: "per_e2e_always",
|
||||||
|
sessionID: session.id,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["/tmp/opencode-e2e-perm-always"],
|
||||||
|
metadata: { description: "Need permission for command" },
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
async (state) => {
|
||||||
|
await page.goto(page.url())
|
||||||
|
await expectPermissionBlocked(page)
|
||||||
|
|
||||||
|
await clearPermissionDock(page, /allow always/i)
|
||||||
|
await state.resolved()
|
||||||
|
await page.goto(page.url())
|
||||||
|
await expectPermissionOpen(page)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("child session question request blocks parent dock and unblocks after submit", async ({
|
||||||
|
page,
|
||||||
|
sdk,
|
||||||
|
gotoSession,
|
||||||
|
}) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
const child = await sdk.session
|
||||||
|
.create({
|
||||||
|
title: "e2e composer dock child question",
|
||||||
|
parentID: session.id,
|
||||||
|
})
|
||||||
|
.then((r) => r.data)
|
||||||
|
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withDockSeed(sdk, child.id, async () => {
|
||||||
|
await seedSessionQuestion(sdk, {
|
||||||
|
sessionID: child.id,
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
header: "Need input",
|
header: "Child input",
|
||||||
question: "Pick one option",
|
question: "Pick one child option",
|
||||||
options: [
|
options: [
|
||||||
{ label: "Continue", description: "Continue now" },
|
{ label: "Continue", description: "Continue child" },
|
||||||
{ label: "Stop", description: "Stop here" },
|
{ label: "Stop", description: "Stop child" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -313,96 +430,40 @@ test("blocked question flow unblocks after submit", async ({ page, withBackendPr
|
|||||||
|
|
||||||
await expectQuestionOpen(page)
|
await expectQuestionOpen(page)
|
||||||
})
|
})
|
||||||
})
|
} finally {
|
||||||
|
await cleanupSession({ sdk, sessionID: child.id })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("blocked question flow supports keyboard shortcuts", async ({ page, withBackendProject }) => {
|
test("child session permission request blocks parent dock and supports allow once", async ({
|
||||||
await withBackendProject(async (project) => {
|
page,
|
||||||
await withDockSession(project.sdk, "e2e composer dock question keyboard", async (session) => {
|
sdk,
|
||||||
await withDockSeed(project.sdk, session.id, async () => {
|
gotoSession,
|
||||||
await project.gotoSession(session.id)
|
}) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
await setAutoAccept(page, false)
|
||||||
|
|
||||||
await seedSessionQuestion(project.sdk, {
|
const child = await sdk.session
|
||||||
sessionID: session.id,
|
.create({
|
||||||
questions: [
|
title: "e2e composer dock child permission",
|
||||||
{
|
parentID: session.id,
|
||||||
header: "Need input",
|
|
||||||
question: "Pick one option",
|
|
||||||
options: [
|
|
||||||
{ label: "Continue", description: "Continue now" },
|
|
||||||
{ label: "Stop", description: "Stop here" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
|
||||||
const first = dock.locator('[data-slot="question-option"]').first()
|
|
||||||
const second = dock.locator('[data-slot="question-option"]').nth(1)
|
|
||||||
|
|
||||||
await expectQuestionBlocked(page)
|
|
||||||
await expect(first).toBeFocused()
|
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
|
||||||
await expect(second).toBeFocused()
|
|
||||||
|
|
||||||
await page.keyboard.press("Space")
|
|
||||||
await page.keyboard.press(`${modKey}+Enter`)
|
|
||||||
await expectQuestionOpen(page)
|
|
||||||
})
|
})
|
||||||
})
|
.then((r) => r.data)
|
||||||
})
|
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked question flow supports escape dismiss", async ({ page, withBackendProject }) => {
|
try {
|
||||||
await withBackendProject(async (project) => {
|
|
||||||
await withDockSession(project.sdk, "e2e composer dock question escape", async (session) => {
|
|
||||||
await withDockSeed(project.sdk, session.id, async () => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
|
|
||||||
await seedSessionQuestion(project.sdk, {
|
|
||||||
sessionID: session.id,
|
|
||||||
questions: [
|
|
||||||
{
|
|
||||||
header: "Need input",
|
|
||||||
question: "Pick one option",
|
|
||||||
options: [
|
|
||||||
{ label: "Continue", description: "Continue now" },
|
|
||||||
{ label: "Stop", description: "Stop here" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
|
||||||
const first = dock.locator('[data-slot="question-option"]').first()
|
|
||||||
|
|
||||||
await expectQuestionBlocked(page)
|
|
||||||
await expect(first).toBeFocused()
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await expectQuestionOpen(page)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked permission flow supports allow once", async ({ page, withBackendProject }) => {
|
|
||||||
await withBackendProject(async (project) => {
|
|
||||||
await withDockSession(project.sdk, "e2e composer dock permission once", async (session) => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
await setAutoAccept(page, false)
|
|
||||||
await withMockPermission(
|
await withMockPermission(
|
||||||
page,
|
page,
|
||||||
{
|
{
|
||||||
id: "per_e2e_once",
|
id: "per_e2e_child",
|
||||||
sessionID: session.id,
|
sessionID: child.id,
|
||||||
permission: "bash",
|
permission: "bash",
|
||||||
patterns: ["/tmp/opencode-e2e-perm-once"],
|
patterns: ["/tmp/opencode-e2e-perm-child"],
|
||||||
metadata: { description: "Need permission for command" },
|
metadata: { description: "Need child permission" },
|
||||||
},
|
},
|
||||||
undefined,
|
{ child },
|
||||||
async (state) => {
|
async (state) => {
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
await expectPermissionBlocked(page)
|
await expectPermissionBlocked(page)
|
||||||
@@ -410,218 +471,67 @@ test("blocked permission flow supports allow once", async ({ page, withBackendPr
|
|||||||
await clearPermissionDock(page, /allow once/i)
|
await clearPermissionDock(page, /allow once/i)
|
||||||
await state.resolved()
|
await state.resolved()
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
|
|
||||||
await expectPermissionOpen(page)
|
await expectPermissionOpen(page)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
} finally {
|
||||||
|
await cleanupSession({ sdk, sessionID: child.id })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("blocked permission flow supports reject", async ({ page, withBackendProject }) => {
|
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
|
||||||
await withBackendProject(async (project) => {
|
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
|
||||||
await withDockSession(project.sdk, "e2e composer dock permission reject", async (session) => {
|
const dock = await todoDock(page, session.id)
|
||||||
await project.gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
await setAutoAccept(page, false)
|
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||||
await withMockPermission(
|
|
||||||
page,
|
|
||||||
{
|
|
||||||
id: "per_e2e_reject",
|
|
||||||
sessionID: session.id,
|
|
||||||
permission: "bash",
|
|
||||||
patterns: ["/tmp/opencode-e2e-perm-reject"],
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
async (state) => {
|
|
||||||
await page.goto(page.url())
|
|
||||||
await expectPermissionBlocked(page)
|
|
||||||
|
|
||||||
await clearPermissionDock(page, /deny/i)
|
try {
|
||||||
await state.resolved()
|
await dock.open([
|
||||||
await page.goto(page.url())
|
{ content: "first task", status: "pending", priority: "high" },
|
||||||
await expectPermissionOpen(page)
|
{ content: "second task", status: "in_progress", priority: "medium" },
|
||||||
},
|
])
|
||||||
)
|
await dock.expectOpen(["pending", "in_progress"])
|
||||||
})
|
|
||||||
|
await dock.collapse()
|
||||||
|
await dock.expectCollapsed(["pending", "in_progress"])
|
||||||
|
|
||||||
|
await dock.expand()
|
||||||
|
await dock.expectOpen(["pending", "in_progress"])
|
||||||
|
|
||||||
|
await dock.finish([
|
||||||
|
{ content: "first task", status: "completed", priority: "high" },
|
||||||
|
{ content: "second task", status: "cancelled", priority: "medium" },
|
||||||
|
])
|
||||||
|
await dock.expectClosed()
|
||||||
|
} finally {
|
||||||
|
await dock.clear()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("blocked permission flow supports allow always", async ({ page, withBackendProject }) => {
|
test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
|
||||||
await withBackendProject(async (project) => {
|
await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
|
||||||
await withDockSession(project.sdk, "e2e composer dock permission always", async (session) => {
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
await project.gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
await setAutoAccept(page, false)
|
|
||||||
await withMockPermission(
|
|
||||||
page,
|
|
||||||
{
|
|
||||||
id: "per_e2e_always",
|
|
||||||
sessionID: session.id,
|
|
||||||
permission: "bash",
|
|
||||||
patterns: ["/tmp/opencode-e2e-perm-always"],
|
|
||||||
metadata: { description: "Need permission for command" },
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
async (state) => {
|
|
||||||
await page.goto(page.url())
|
|
||||||
await expectPermissionBlocked(page)
|
|
||||||
|
|
||||||
await clearPermissionDock(page, /allow always/i)
|
await seedSessionQuestion(sdk, {
|
||||||
await state.resolved()
|
sessionID: session.id,
|
||||||
await page.goto(page.url())
|
questions: [
|
||||||
await expectPermissionOpen(page)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("child session question request blocks parent dock and unblocks after submit", async ({
|
|
||||||
page,
|
|
||||||
withBackendProject,
|
|
||||||
}) => {
|
|
||||||
await withBackendProject(async (project) => {
|
|
||||||
await withDockSession(project.sdk, "e2e composer dock child question parent", async (session) => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
|
|
||||||
const child = await project.sdk.session
|
|
||||||
.create({
|
|
||||||
title: "e2e composer dock child question",
|
|
||||||
parentID: session.id,
|
|
||||||
})
|
|
||||||
.then((r) => r.data)
|
|
||||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withDockSeed(project.sdk, child.id, async () => {
|
|
||||||
await seedSessionQuestion(project.sdk, {
|
|
||||||
sessionID: child.id,
|
|
||||||
questions: [
|
|
||||||
{
|
|
||||||
header: "Child input",
|
|
||||||
question: "Pick one child option",
|
|
||||||
options: [
|
|
||||||
{ label: "Continue", description: "Continue child" },
|
|
||||||
{ label: "Stop", description: "Stop child" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
|
||||||
await expectQuestionBlocked(page)
|
|
||||||
|
|
||||||
await dock.locator('[data-slot="question-option"]').first().click()
|
|
||||||
await dock.getByRole("button", { name: /submit/i }).click()
|
|
||||||
|
|
||||||
await expectQuestionOpen(page)
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("child session permission request blocks parent dock and supports allow once", async ({
|
|
||||||
page,
|
|
||||||
withBackendProject,
|
|
||||||
}) => {
|
|
||||||
await withBackendProject(async (project) => {
|
|
||||||
await withDockSession(project.sdk, "e2e composer dock child permission parent", async (session) => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
await setAutoAccept(page, false)
|
|
||||||
|
|
||||||
const child = await project.sdk.session
|
|
||||||
.create({
|
|
||||||
title: "e2e composer dock child permission",
|
|
||||||
parentID: session.id,
|
|
||||||
})
|
|
||||||
.then((r) => r.data)
|
|
||||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withMockPermission(
|
|
||||||
page,
|
|
||||||
{
|
{
|
||||||
id: "per_e2e_child",
|
header: "Need input",
|
||||||
sessionID: child.id,
|
question: "Pick one option",
|
||||||
permission: "bash",
|
options: [{ label: "Continue", description: "Continue now" }],
|
||||||
patterns: ["/tmp/opencode-e2e-perm-child"],
|
|
||||||
metadata: { description: "Need child permission" },
|
|
||||||
},
|
},
|
||||||
{ child },
|
],
|
||||||
async (state) => {
|
|
||||||
await page.goto(page.url())
|
|
||||||
await expectPermissionBlocked(page)
|
|
||||||
|
|
||||||
await clearPermissionDock(page, /allow once/i)
|
|
||||||
await state.resolved()
|
|
||||||
await page.goto(page.url())
|
|
||||||
|
|
||||||
await expectPermissionOpen(page)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("todo dock transitions and collapse behavior", async ({ page, withBackendProject }) => {
|
|
||||||
await withBackendProject(async (project) => {
|
|
||||||
await withDockSession(project.sdk, "e2e composer dock todo", async (session) => {
|
|
||||||
const dock = await todoDock(page, session.id)
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await dock.open([
|
|
||||||
{ content: "first task", status: "pending", priority: "high" },
|
|
||||||
{ content: "second task", status: "in_progress", priority: "medium" },
|
|
||||||
])
|
|
||||||
await dock.expectOpen(["pending", "in_progress"])
|
|
||||||
|
|
||||||
await dock.collapse()
|
|
||||||
await dock.expectCollapsed(["pending", "in_progress"])
|
|
||||||
|
|
||||||
await dock.expand()
|
|
||||||
await dock.expectOpen(["pending", "in_progress"])
|
|
||||||
|
|
||||||
await dock.finish([
|
|
||||||
{ content: "first task", status: "completed", priority: "high" },
|
|
||||||
{ content: "second task", status: "cancelled", priority: "medium" },
|
|
||||||
])
|
|
||||||
await dock.expectClosed()
|
|
||||||
} finally {
|
|
||||||
await dock.clear()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keyboard focus stays off prompt while blocked", async ({ page, withBackendProject }) => {
|
|
||||||
await withBackendProject(async (project) => {
|
|
||||||
await withDockSession(project.sdk, "e2e composer dock keyboard", async (session) => {
|
|
||||||
await withDockSeed(project.sdk, session.id, async () => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
|
|
||||||
await seedSessionQuestion(project.sdk, {
|
|
||||||
sessionID: session.id,
|
|
||||||
questions: [
|
|
||||||
{
|
|
||||||
header: "Need input",
|
|
||||||
question: "Pick one option",
|
|
||||||
options: [{ label: "Continue", description: "Continue now" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
await expectQuestionBlocked(page)
|
|
||||||
|
|
||||||
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
|
||||||
await page.keyboard.type("abc")
|
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await expectQuestionBlocked(page)
|
||||||
|
|
||||||
|
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
||||||
|
await page.keyboard.type("abc")
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -49,13 +49,13 @@ async function seedConversation(input: {
|
|||||||
return { prompt, userMessageID }
|
return { prompt, userMessageID }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("slash undo sets revert and restores prior prompt", async ({ page, withBackendProject }) => {
|
test("slash undo sets revert and restores prior prompt", async ({ page, withProject }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const token = `undo_${Date.now()}`
|
const token = `undo_${Date.now()}`
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await withProject(async (project) => {
|
||||||
const sdk = project.sdk
|
const sdk = createSdk(project.directory)
|
||||||
|
|
||||||
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
|
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
@@ -81,13 +81,13 @@ test("slash undo sets revert and restores prior prompt", async ({ page, withBack
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("slash redo clears revert and restores latest state", async ({ page, withBackendProject }) => {
|
test("slash redo clears revert and restores latest state", async ({ page, withProject }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const token = `redo_${Date.now()}`
|
const token = `redo_${Date.now()}`
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await withProject(async (project) => {
|
||||||
const sdk = project.sdk
|
const sdk = createSdk(project.directory)
|
||||||
|
|
||||||
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
|
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
@@ -128,14 +128,14 @@ test("slash redo clears revert and restores latest state", async ({ page, withBa
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("slash undo/redo traverses multi-step revert stack", async ({ page, withBackendProject }) => {
|
test("slash undo/redo traverses multi-step revert stack", async ({ page, withProject }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const firstToken = `undo_redo_first_${Date.now()}`
|
const firstToken = `undo_redo_first_${Date.now()}`
|
||||||
const secondToken = `undo_redo_second_${Date.now()}`
|
const secondToken = `undo_redo_second_${Date.now()}`
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await withProject(async (project) => {
|
||||||
const sdk = project.sdk
|
const sdk = createSdk(project.directory)
|
||||||
|
|
||||||
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
|
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
|
|||||||
@@ -31,152 +31,144 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
|
|||||||
.toBeGreaterThan(0)
|
.toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("session can be renamed via header menu", async ({ page, withBackendProject }) => {
|
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const originalTitle = `e2e rename test ${stamp}`
|
const originalTitle = `e2e rename test ${stamp}`
|
||||||
const renamedTitle = `e2e renamed ${stamp}`
|
const renamedTitle = `e2e renamed ${stamp}`
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await withSession(sdk, originalTitle, async (session) => {
|
||||||
await withSession(project.sdk, originalTitle, async (session) => {
|
await seedMessage(sdk, session.id)
|
||||||
await seedMessage(project.sdk, session.id)
|
await gotoSession(session.id)
|
||||||
await project.gotoSession(session.id)
|
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
|
||||||
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
|
|
||||||
|
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
await clickMenuItem(menu, /rename/i)
|
await clickMenuItem(menu, /rename/i)
|
||||||
|
|
||||||
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
|
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
|
||||||
await expect(input).toBeVisible()
|
await expect(input).toBeVisible()
|
||||||
await expect(input).toBeFocused()
|
await expect(input).toBeFocused()
|
||||||
await input.fill(renamedTitle)
|
await input.fill(renamedTitle)
|
||||||
await expect(input).toHaveValue(renamedTitle)
|
await expect(input).toHaveValue(renamedTitle)
|
||||||
await input.press("Enter")
|
await input.press("Enter")
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.title
|
return data?.title
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
)
|
)
|
||||||
.toBe(renamedTitle)
|
.toBe(renamedTitle)
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
|
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session can be archived via header menu", async ({ page, withBackendProject }) => {
|
test("session can be archived via header menu", async ({ page, sdk, gotoSession }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const title = `e2e archive test ${stamp}`
|
const title = `e2e archive test ${stamp}`
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await withSession(sdk, title, async (session) => {
|
||||||
await withSession(project.sdk, title, async (session) => {
|
await seedMessage(sdk, session.id)
|
||||||
await seedMessage(project.sdk, session.id)
|
await gotoSession(session.id)
|
||||||
await project.gotoSession(session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
await clickMenuItem(menu, /archive/i)
|
||||||
await clickMenuItem(menu, /archive/i)
|
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.time?.archived
|
return data?.time?.archived
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
)
|
)
|
||||||
.not.toBeUndefined()
|
.not.toBeUndefined()
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session can be deleted via header menu", async ({ page, withBackendProject }) => {
|
test("session can be deleted via header menu", async ({ page, sdk, gotoSession }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const title = `e2e delete test ${stamp}`
|
const title = `e2e delete test ${stamp}`
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await withSession(sdk, title, async (session) => {
|
||||||
await withSession(project.sdk, title, async (session) => {
|
await seedMessage(sdk, session.id)
|
||||||
await seedMessage(project.sdk, session.id)
|
await gotoSession(session.id)
|
||||||
await project.gotoSession(session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
await clickMenuItem(menu, /delete/i)
|
||||||
await clickMenuItem(menu, /delete/i)
|
await confirmDialog(page, /delete/i)
|
||||||
await confirmDialog(page, /delete/i)
|
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await project.sdk.session
|
const data = await sdk.session
|
||||||
.get({ sessionID: session.id })
|
.get({ sessionID: session.id })
|
||||||
.then((r) => r.data)
|
.then((r) => r.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
return data?.id
|
return data?.id
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
)
|
)
|
||||||
.toBeUndefined()
|
.toBeUndefined()
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session can be shared and unshared via header button", async ({ page, withBackendProject }) => {
|
test("session can be shared and unshared via header button", async ({ page, sdk, gotoSession }) => {
|
||||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||||
|
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const title = `e2e share test ${stamp}`
|
const title = `e2e share test ${stamp}`
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await withSession(sdk, title, async (session) => {
|
||||||
await withSession(project.sdk, title, async (session) => {
|
await seedMessage(sdk, session.id)
|
||||||
await seedMessage(project.sdk, session.id)
|
await gotoSession(session.id)
|
||||||
await project.gotoSession(session.id)
|
|
||||||
|
|
||||||
const shared = await openSharePopover(page)
|
const shared = await openSharePopover(page)
|
||||||
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
||||||
await expect(publish).toBeVisible({ timeout: 30_000 })
|
await expect(publish).toBeVisible({ timeout: 30_000 })
|
||||||
await publish.click()
|
await publish.click()
|
||||||
|
|
||||||
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
|
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.share?.url || undefined
|
return data?.share?.url || undefined
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
)
|
)
|
||||||
.not.toBeUndefined()
|
.not.toBeUndefined()
|
||||||
|
|
||||||
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
||||||
await expect(unpublish).toBeVisible({ timeout: 30_000 })
|
await expect(unpublish).toBeVisible({ timeout: 30_000 })
|
||||||
await unpublish.click()
|
await unpublish.click()
|
||||||
|
|
||||||
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.share?.url || undefined
|
return data?.share?.url || undefined
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
)
|
)
|
||||||
.toBeUndefined()
|
.toBeUndefined()
|
||||||
|
|
||||||
const unshared = await openSharePopover(page)
|
const unshared = await openSharePopover(page)
|
||||||
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,21 +26,21 @@ export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("
|
|||||||
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
||||||
export const terminalToggleKey = "Control+Backquote"
|
export const terminalToggleKey = "Control+Backquote"
|
||||||
|
|
||||||
export function createSdk(directory?: string, baseUrl = serverUrl) {
|
export function createSdk(directory?: string) {
|
||||||
return createOpencodeClient({ baseUrl, directory, throwOnError: true })
|
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveDirectory(directory: string, baseUrl = serverUrl) {
|
export async function resolveDirectory(directory: string) {
|
||||||
return createSdk(directory, baseUrl)
|
return createSdk(directory)
|
||||||
.path.get()
|
.path.get()
|
||||||
.then((x) => x.data?.directory ?? directory)
|
.then((x) => x.data?.directory ?? directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWorktree(baseUrl = serverUrl) {
|
export async function getWorktree() {
|
||||||
const sdk = createSdk(undefined, baseUrl)
|
const sdk = createSdk()
|
||||||
const result = await sdk.path.get()
|
const result = await sdk.path.get()
|
||||||
const data = result.data
|
const data = result.data
|
||||||
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${baseUrl}/path`)
|
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${serverUrl}/path`)
|
||||||
return data.worktree
|
return data.worktree
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1344,9 +1344,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||||
autocorrect={store.mode === "normal" ? "on" : "off"}
|
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||||
spellcheck={store.mode === "normal"}
|
spellcheck={store.mode === "normal"}
|
||||||
inputMode="text"
|
|
||||||
// @ts-expect-error
|
|
||||||
autocomplete="off"
|
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onCompositionStart={handleCompositionStart}
|
onCompositionStart={handleCompositionStart}
|
||||||
|
|||||||
@@ -100,30 +100,6 @@ describe("buildRequestParts", () => {
|
|||||||
expect(synthetic).toHaveLength(1)
|
expect(synthetic).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("adds file parts for @mentions inside comment text", () => {
|
|
||||||
const result = buildRequestParts({
|
|
||||||
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
|
|
||||||
context: [
|
|
||||||
{
|
|
||||||
key: "ctx:comment-mention",
|
|
||||||
type: "file",
|
|
||||||
path: "src/review.ts",
|
|
||||||
comment: "Compare with @src/shared.ts and @src/review.ts.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
images: [],
|
|
||||||
text: "look",
|
|
||||||
messageID: "msg_comment_mentions",
|
|
||||||
sessionID: "ses_comment_mentions",
|
|
||||||
sessionDirectory: "/repo",
|
|
||||||
})
|
|
||||||
|
|
||||||
const files = result.requestParts.filter((part) => part.type === "file")
|
|
||||||
expect(files).toHaveLength(2)
|
|
||||||
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true)
|
|
||||||
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("handles Windows paths correctly (simulated on macOS)", () => {
|
test("handles Windows paths correctly (simulated on macOS)", () => {
|
||||||
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
||||||
|
|
||||||
|
|||||||
@@ -39,16 +39,6 @@ const absolute = (directory: string, path: string) => {
|
|||||||
const fileQuery = (selection: FileSelection | undefined) =>
|
const fileQuery = (selection: FileSelection | undefined) =>
|
||||||
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
||||||
|
|
||||||
const mention = /(^|[\s([{"'])@(\S+)/g
|
|
||||||
|
|
||||||
const parseCommentMentions = (comment: string) => {
|
|
||||||
return Array.from(comment.matchAll(mention)).flatMap((match) => {
|
|
||||||
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
|
|
||||||
if (!path) return []
|
|
||||||
return [path]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
||||||
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
||||||
|
|
||||||
@@ -148,21 +138,6 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
|||||||
|
|
||||||
if (!comment) return [filePart]
|
if (!comment) return [filePart]
|
||||||
|
|
||||||
const mentions = parseCommentMentions(comment).flatMap((path) => {
|
|
||||||
const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
|
|
||||||
if (used.has(url)) return []
|
|
||||||
used.add(url)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
type: "file",
|
|
||||||
mime: "text/plain",
|
|
||||||
url,
|
|
||||||
filename: getFilename(path),
|
|
||||||
} satisfies PromptRequestPart,
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: Identifier.ascending("part"),
|
id: Identifier.ascending("part"),
|
||||||
@@ -178,7 +153,6 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
|||||||
}),
|
}),
|
||||||
} satisfies PromptRequestPart,
|
} satisfies PromptRequestPart,
|
||||||
filePart,
|
filePart,
|
||||||
...mentions,
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1046,9 +1046,6 @@ export default function Page() {
|
|||||||
onLineCommentUpdate={updateCommentInContext}
|
onLineCommentUpdate={updateCommentInContext}
|
||||||
onLineCommentDelete={removeCommentFromContext}
|
onLineCommentDelete={removeCommentFromContext}
|
||||||
lineCommentActions={reviewCommentActions()}
|
lineCommentActions={reviewCommentActions()}
|
||||||
commentMentions={{
|
|
||||||
items: file.searchFilesAndDirectories,
|
|
||||||
}}
|
|
||||||
comments={comments.all()}
|
comments={comments.all()}
|
||||||
focusedComment={comments.focus()}
|
focusedComment={comments.focus()}
|
||||||
onFocusedCommentChange={comments.setFocus}
|
onFocusedCommentChange={comments.setFocus}
|
||||||
|
|||||||
@@ -29,20 +29,16 @@ function Option(props: {
|
|||||||
label: string
|
label: string
|
||||||
description?: string
|
description?: string
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
ref?: (el: HTMLButtonElement) => void
|
|
||||||
onFocus?: VoidFunction
|
|
||||||
onClick: VoidFunction
|
onClick: VoidFunction
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
ref={props.ref}
|
|
||||||
data-slot="question-option"
|
data-slot="question-option"
|
||||||
data-picked={props.picked}
|
data-picked={props.picked}
|
||||||
role={props.multi ? "checkbox" : "radio"}
|
role={props.multi ? "checkbox" : "radio"}
|
||||||
aria-checked={props.picked}
|
aria-checked={props.picked}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
onFocus={props.onFocus}
|
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
>
|
>
|
||||||
<Mark multi={props.multi} picked={props.picked} />
|
<Mark multi={props.multi} picked={props.picked} />
|
||||||
@@ -70,21 +66,16 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
custom: cached?.custom ?? ([] as string[]),
|
custom: cached?.custom ?? ([] as string[]),
|
||||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||||
editing: false,
|
editing: false,
|
||||||
focus: 0,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
let root: HTMLDivElement | undefined
|
let root: HTMLDivElement | undefined
|
||||||
let customRef: HTMLButtonElement | undefined
|
|
||||||
let optsRef: HTMLButtonElement[] = []
|
|
||||||
let replied = false
|
let replied = false
|
||||||
let focusFrame: number | undefined
|
|
||||||
|
|
||||||
const question = createMemo(() => questions()[store.tab])
|
const question = createMemo(() => questions()[store.tab])
|
||||||
const options = createMemo(() => question()?.options ?? [])
|
const options = createMemo(() => question()?.options ?? [])
|
||||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||||
const on = createMemo(() => store.customOn[store.tab] === true)
|
const on = createMemo(() => store.customOn[store.tab] === true)
|
||||||
const multi = createMemo(() => question()?.multiple === true)
|
const multi = createMemo(() => question()?.multiple === true)
|
||||||
const count = createMemo(() => options().length + 1)
|
|
||||||
|
|
||||||
const summary = createMemo(() => {
|
const summary = createMemo(() => {
|
||||||
const n = Math.min(store.tab + 1, total())
|
const n = Math.min(store.tab + 1, total())
|
||||||
@@ -138,29 +129,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
root.style.setProperty("--question-prompt-max-height", `${max}px`)
|
root.style.setProperty("--question-prompt-max-height", `${max}px`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const clamp = (i: number) => Math.max(0, Math.min(count() - 1, i))
|
|
||||||
|
|
||||||
const pickFocus = (tab: number = store.tab) => {
|
|
||||||
const list = questions()[tab]?.options ?? []
|
|
||||||
if (store.customOn[tab] === true) return list.length
|
|
||||||
return Math.max(
|
|
||||||
0,
|
|
||||||
list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const focus = (i: number) => {
|
|
||||||
const next = clamp(i)
|
|
||||||
setStore("focus", next)
|
|
||||||
if (store.editing) return
|
|
||||||
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
|
|
||||||
focusFrame = requestAnimationFrame(() => {
|
|
||||||
focusFrame = undefined
|
|
||||||
const el = next === options().length ? customRef : optsRef[next]
|
|
||||||
el?.focus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
let raf: number | undefined
|
let raf: number | undefined
|
||||||
const update = () => {
|
const update = () => {
|
||||||
@@ -185,12 +153,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
observer.disconnect()
|
observer.disconnect()
|
||||||
if (raf !== undefined) cancelAnimationFrame(raf)
|
if (raf !== undefined) cancelAnimationFrame(raf)
|
||||||
})
|
})
|
||||||
|
|
||||||
focus(pickFocus())
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
|
|
||||||
if (replied) return
|
if (replied) return
|
||||||
cache.set(props.request.id, {
|
cache.set(props.request.id, {
|
||||||
tab: store.tab,
|
tab: store.tab,
|
||||||
@@ -266,7 +231,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
|
|
||||||
const customToggle = () => {
|
const customToggle = () => {
|
||||||
if (sending()) return
|
if (sending()) return
|
||||||
setStore("focus", options().length)
|
|
||||||
|
|
||||||
if (!multi()) {
|
if (!multi()) {
|
||||||
setStore("customOn", store.tab, true)
|
setStore("customOn", store.tab, true)
|
||||||
@@ -286,68 +250,15 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
const value = input().trim()
|
const value = input().trim()
|
||||||
if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
|
if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
focus(options().length)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const customOpen = () => {
|
const customOpen = () => {
|
||||||
if (sending()) return
|
if (sending()) return
|
||||||
setStore("focus", options().length)
|
|
||||||
if (!on()) setStore("customOn", store.tab, true)
|
if (!on()) setStore("customOn", store.tab, true)
|
||||||
setStore("editing", true)
|
setStore("editing", true)
|
||||||
customUpdate(input(), true)
|
customUpdate(input(), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const move = (step: number) => {
|
|
||||||
if (store.editing || sending()) return
|
|
||||||
focus(store.focus + step)
|
|
||||||
}
|
|
||||||
|
|
||||||
const nav = (event: KeyboardEvent) => {
|
|
||||||
if (event.defaultPrevented) return
|
|
||||||
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
event.preventDefault()
|
|
||||||
void reject()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const mod = (event.metaKey || event.ctrlKey) && !event.altKey
|
|
||||||
if (mod && event.key === "Enter") {
|
|
||||||
if (event.repeat) return
|
|
||||||
event.preventDefault()
|
|
||||||
next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const target =
|
|
||||||
event.target instanceof HTMLElement ? event.target.closest('[data-slot="question-options"]') : undefined
|
|
||||||
if (store.editing) return
|
|
||||||
if (!(target instanceof HTMLElement)) return
|
|
||||||
if (event.altKey || event.ctrlKey || event.metaKey) return
|
|
||||||
|
|
||||||
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
|
|
||||||
event.preventDefault()
|
|
||||||
move(1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
|
|
||||||
event.preventDefault()
|
|
||||||
move(-1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === "Home") {
|
|
||||||
event.preventDefault()
|
|
||||||
focus(0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key !== "End") return
|
|
||||||
event.preventDefault()
|
|
||||||
focus(count() - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectOption = (optIndex: number) => {
|
const selectOption = (optIndex: number) => {
|
||||||
if (sending()) return
|
if (sending()) return
|
||||||
|
|
||||||
@@ -359,7 +270,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
const opt = options()[optIndex]
|
const opt = options()[optIndex]
|
||||||
if (!opt) return
|
if (!opt) return
|
||||||
if (multi()) {
|
if (multi()) {
|
||||||
setStore("editing", false)
|
|
||||||
toggle(opt.label)
|
toggle(opt.label)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -369,7 +279,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
const commitCustom = () => {
|
const commitCustom = () => {
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
customUpdate(input())
|
customUpdate(input())
|
||||||
focus(options().length)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resizeInput = (el: HTMLTextAreaElement) => {
|
const resizeInput = (el: HTMLTextAreaElement) => {
|
||||||
@@ -399,33 +308,27 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const tab = store.tab + 1
|
setStore("tab", store.tab + 1)
|
||||||
setStore("tab", tab)
|
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
focus(pickFocus(tab))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const back = () => {
|
const back = () => {
|
||||||
if (sending()) return
|
if (sending()) return
|
||||||
if (store.tab <= 0) return
|
if (store.tab <= 0) return
|
||||||
const tab = store.tab - 1
|
setStore("tab", store.tab - 1)
|
||||||
setStore("tab", tab)
|
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
focus(pickFocus(tab))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const jump = (tab: number) => {
|
const jump = (tab: number) => {
|
||||||
if (sending()) return
|
if (sending()) return
|
||||||
setStore("tab", tab)
|
setStore("tab", tab)
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
focus(pickFocus(tab))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DockPrompt
|
<DockPrompt
|
||||||
kind="question"
|
kind="question"
|
||||||
ref={(el) => (root = el)}
|
ref={(el) => (root = el)}
|
||||||
onKeyDown={nav}
|
|
||||||
header={
|
header={
|
||||||
<>
|
<>
|
||||||
<div data-slot="question-header-title">{summary()}</div>
|
<div data-slot="question-header-title">{summary()}</div>
|
||||||
@@ -448,7 +351,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
|
<Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
|
||||||
{language.t("ui.common.dismiss")}
|
{language.t("ui.common.dismiss")}
|
||||||
</Button>
|
</Button>
|
||||||
<div data-slot="question-footer-actions">
|
<div data-slot="question-footer-actions">
|
||||||
@@ -457,13 +360,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
{language.t("ui.common.back")}
|
{language.t("ui.common.back")}
|
||||||
</Button>
|
</Button>
|
||||||
</Show>
|
</Show>
|
||||||
<Button
|
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
|
||||||
variant={last() ? "primary" : "secondary"}
|
|
||||||
size="large"
|
|
||||||
disabled={sending()}
|
|
||||||
onClick={next}
|
|
||||||
aria-keyshortcuts="Meta+Enter Control+Enter"
|
|
||||||
>
|
|
||||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -483,8 +380,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
label={opt.label}
|
label={opt.label}
|
||||||
description={opt.description}
|
description={opt.description}
|
||||||
disabled={sending()}
|
disabled={sending()}
|
||||||
ref={(el) => (optsRef[i()] = el)}
|
|
||||||
onFocus={() => setStore("focus", i())}
|
|
||||||
onClick={() => selectOption(i())}
|
onClick={() => selectOption(i())}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -495,14 +390,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
fallback={
|
fallback={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
ref={customRef}
|
|
||||||
data-slot="question-option"
|
data-slot="question-option"
|
||||||
data-custom="true"
|
data-custom="true"
|
||||||
data-picked={on()}
|
data-picked={on()}
|
||||||
role={multi() ? "checkbox" : "radio"}
|
role={multi() ? "checkbox" : "radio"}
|
||||||
aria-checked={on()}
|
aria-checked={on()}
|
||||||
disabled={sending()}
|
disabled={sending()}
|
||||||
onFocus={() => setStore("focus", options().length)}
|
|
||||||
onClick={customOpen}
|
onClick={customOpen}
|
||||||
>
|
>
|
||||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||||
@@ -547,10 +440,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
focus(options().length)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
|
||||||
if (e.key !== "Enter" || e.shiftKey) return
|
if (e.key !== "Enter" || e.shiftKey) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
commitCustom()
|
commitCustom()
|
||||||
|
|||||||
@@ -302,9 +302,6 @@ export function FileTabContent(props: { tab: string }) {
|
|||||||
comments: fileComments,
|
comments: fileComments,
|
||||||
label: language.t("ui.lineComment.submit"),
|
label: language.t("ui.lineComment.submit"),
|
||||||
draftKey: () => path() ?? props.tab,
|
draftKey: () => path() ?? props.tab,
|
||||||
mention: {
|
|
||||||
items: file.searchFilesAndDirectories,
|
|
||||||
},
|
|
||||||
state: {
|
state: {
|
||||||
opened: () => note.openedComment,
|
opened: () => note.openedComment,
|
||||||
setOpened: (id) => setNote("openedComment", id),
|
setOpened: (id) => setNote("openedComment", id),
|
||||||
|
|||||||
@@ -30,9 +30,6 @@ export interface SessionReviewTabProps {
|
|||||||
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
|
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
|
||||||
focusedFile?: string
|
focusedFile?: string
|
||||||
onScrollRef?: (el: HTMLDivElement) => void
|
onScrollRef?: (el: HTMLDivElement) => void
|
||||||
commentMentions?: {
|
|
||||||
items: (query: string) => string[] | Promise<string[]>
|
|
||||||
}
|
|
||||||
classes?: {
|
classes?: {
|
||||||
root?: string
|
root?: string
|
||||||
header?: string
|
header?: string
|
||||||
@@ -165,7 +162,6 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
|||||||
onLineCommentUpdate={props.onLineCommentUpdate}
|
onLineCommentUpdate={props.onLineCommentUpdate}
|
||||||
onLineCommentDelete={props.onLineCommentDelete}
|
onLineCommentDelete={props.onLineCommentDelete}
|
||||||
lineCommentActions={props.lineCommentActions}
|
lineCommentActions={props.lineCommentActions}
|
||||||
lineCommentMention={props.commentMentions}
|
|
||||||
comments={props.comments}
|
comments={props.comments}
|
||||||
focusedComment={props.focusedComment}
|
focusedComment={props.focusedComment}
|
||||||
onFocusedCommentChange={props.onFocusedCommentChange}
|
onFocusedCommentChange={props.onFocusedCommentChange}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop-electron",
|
"name": "@opencode-ai/desktop-electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://opencode.ai",
|
"homepage": "https://opencode.ai",
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { app } from "electron"
|
|||||||
import treeKill from "tree-kill"
|
import treeKill from "tree-kill"
|
||||||
|
|
||||||
import { WSL_ENABLED_KEY } from "./constants"
|
import { WSL_ENABLED_KEY } from "./constants"
|
||||||
import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env"
|
|
||||||
import { store } from "./store"
|
import { store } from "./store"
|
||||||
|
|
||||||
const CLI_INSTALL_DIR = ".opencode/bin"
|
const CLI_INSTALL_DIR = ".opencode/bin"
|
||||||
@@ -136,7 +135,7 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
|
|||||||
const base = Object.fromEntries(
|
const base = Object.fromEntries(
|
||||||
Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||||
)
|
)
|
||||||
const env = {
|
const envs = {
|
||||||
...base,
|
...base,
|
||||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||||
@@ -144,10 +143,8 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
|
|||||||
XDG_STATE_HOME: app.getPath("userData"),
|
XDG_STATE_HOME: app.getPath("userData"),
|
||||||
...extraEnv,
|
...extraEnv,
|
||||||
}
|
}
|
||||||
const shell = process.platform === "win32" ? null : getUserShell()
|
|
||||||
const envs = shell ? mergeShellEnv(loadShellEnv(shell), env) : env
|
|
||||||
|
|
||||||
const { cmd, cmdArgs } = buildCommand(args, envs, shell)
|
const { cmd, cmdArgs } = buildCommand(args, envs)
|
||||||
console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
|
console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
|
||||||
const child = spawn(cmd, cmdArgs, {
|
const child = spawn(cmd, cmdArgs, {
|
||||||
env: envs,
|
env: envs,
|
||||||
@@ -213,7 +210,7 @@ function handleSqliteProgress(events: EventEmitter, line: string) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCommand(args: string, env: Record<string, string>, shell: string | null) {
|
function buildCommand(args: string, env: Record<string, string>) {
|
||||||
if (process.platform === "win32" && isWslEnabled()) {
|
if (process.platform === "win32" && isWslEnabled()) {
|
||||||
console.log(`[cli] Using WSL mode`)
|
console.log(`[cli] Using WSL mode`)
|
||||||
const version = app.getVersion()
|
const version = app.getVersion()
|
||||||
@@ -236,10 +233,10 @@ function buildCommand(args: string, env: Record<string, string>, shell: string |
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sidecar = getSidecarPath()
|
const sidecar = getSidecarPath()
|
||||||
const user = shell || getUserShell()
|
const shell = process.env.SHELL || "/bin/sh"
|
||||||
const line = user.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
|
const line = shell.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
|
||||||
console.log(`[cli] Unix mode, shell: ${user}, command: ${line}`)
|
console.log(`[cli] Unix mode, shell: ${shell}, command: ${line}`)
|
||||||
return { cmd: user, cmdArgs: ["-l", "-c", line] }
|
return { cmd: shell, cmdArgs: ["-l", "-c", line] }
|
||||||
}
|
}
|
||||||
|
|
||||||
function envPrefix(env: Record<string, string>) {
|
function envPrefix(env: Record<string, string>) {
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import { isNushell, mergeShellEnv, parseShellEnv } from "./shell-env"
|
|
||||||
|
|
||||||
describe("shell env", () => {
|
|
||||||
test("parseShellEnv supports null-delimited pairs", () => {
|
|
||||||
const env = parseShellEnv(Buffer.from("PATH=/usr/bin:/bin\0FOO=bar=baz\0\0"))
|
|
||||||
|
|
||||||
expect(env.PATH).toBe("/usr/bin:/bin")
|
|
||||||
expect(env.FOO).toBe("bar=baz")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("parseShellEnv ignores invalid entries", () => {
|
|
||||||
const env = parseShellEnv(Buffer.from("INVALID\0=empty\0OK=1\0"))
|
|
||||||
|
|
||||||
expect(Object.keys(env).length).toBe(1)
|
|
||||||
expect(env.OK).toBe("1")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("mergeShellEnv keeps explicit overrides", () => {
|
|
||||||
const env = mergeShellEnv(
|
|
||||||
{
|
|
||||||
PATH: "/shell/path",
|
|
||||||
HOME: "/tmp/home",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
PATH: "/desktop/path",
|
|
||||||
OPENCODE_CLIENT: "desktop",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(env.PATH).toBe("/desktop/path")
|
|
||||||
expect(env.HOME).toBe("/tmp/home")
|
|
||||||
expect(env.OPENCODE_CLIENT).toBe("desktop")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("isNushell handles path and binary name", () => {
|
|
||||||
expect(isNushell("nu")).toBe(true)
|
|
||||||
expect(isNushell("/opt/homebrew/bin/nu")).toBe(true)
|
|
||||||
expect(isNushell("C:\\Program Files\\nu.exe")).toBe(true)
|
|
||||||
expect(isNushell("/bin/zsh")).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { spawnSync } from "node:child_process"
|
|
||||||
import { basename } from "node:path"
|
|
||||||
|
|
||||||
const SHELL_ENV_TIMEOUT = 5_000
|
|
||||||
|
|
||||||
type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
|
|
||||||
|
|
||||||
export function getUserShell() {
|
|
||||||
return process.env.SHELL || "/bin/sh"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseShellEnv(out: Buffer) {
|
|
||||||
const env: Record<string, string> = {}
|
|
||||||
for (const line of out.toString("utf8").split("\0")) {
|
|
||||||
if (!line) continue
|
|
||||||
const ix = line.indexOf("=")
|
|
||||||
if (ix <= 0) continue
|
|
||||||
env[line.slice(0, ix)] = line.slice(ix + 1)
|
|
||||||
}
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
|
|
||||||
function probeShellEnv(shell: string, mode: "-il" | "-l"): Probe {
|
|
||||||
const out = spawnSync(shell, [mode, "-c", "env -0"], {
|
|
||||||
stdio: ["ignore", "pipe", "ignore"],
|
|
||||||
timeout: SHELL_ENV_TIMEOUT,
|
|
||||||
windowsHide: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const err = out.error as NodeJS.ErrnoException | undefined
|
|
||||||
if (err) {
|
|
||||||
if (err.code === "ETIMEDOUT") return { type: "Timeout" }
|
|
||||||
console.log(`[cli] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
|
|
||||||
return { type: "Unavailable" }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (out.status !== 0) {
|
|
||||||
console.log(`[cli] Shell env probe exited with non-zero status for ${shell} ${mode}`)
|
|
||||||
return { type: "Unavailable" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const env = parseShellEnv(out.stdout)
|
|
||||||
if (Object.keys(env).length === 0) {
|
|
||||||
console.log(`[cli] Shell env probe returned empty env for ${shell} ${mode}`)
|
|
||||||
return { type: "Unavailable" }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { type: "Loaded", value: env }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isNushell(shell: string) {
|
|
||||||
const name = basename(shell).toLowerCase()
|
|
||||||
const raw = shell.toLowerCase()
|
|
||||||
return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadShellEnv(shell: string) {
|
|
||||||
if (isNushell(shell)) {
|
|
||||||
console.log(`[cli] Skipping shell env probe for nushell: ${shell}`)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const interactive = probeShellEnv(shell, "-il")
|
|
||||||
if (interactive.type === "Loaded") {
|
|
||||||
console.log(`[cli] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
|
|
||||||
return interactive.value
|
|
||||||
}
|
|
||||||
if (interactive.type === "Timeout") {
|
|
||||||
console.warn(`[cli] Interactive shell env probe timed out: ${shell}`)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const login = probeShellEnv(shell, "-l")
|
|
||||||
if (login.type === "Loaded") {
|
|
||||||
console.log(`[cli] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
|
|
||||||
return login.value
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn(`[cli] Falling back to app environment: ${shell}`)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergeShellEnv(shell: Record<string, string> | null, env: Record<string, string>) {
|
|
||||||
return {
|
|
||||||
...(shell || {}),
|
|
||||||
...env,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
id = "opencode"
|
id = "opencode"
|
||||||
name = "OpenCode"
|
name = "OpenCode"
|
||||||
description = "The open source coding agent."
|
description = "The open source coding agent."
|
||||||
version = "1.3.13"
|
version = "1.3.10"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Anomaly"]
|
authors = ["Anomaly"]
|
||||||
repository = "https://github.com/anomalyco/opencode"
|
repository = "https://github.com/anomalyco/opencode"
|
||||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
|||||||
icon = "./icons/opencode.svg"
|
icon = "./icons/opencode.svg"
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-aarch64]
|
[agent_servers.opencode.targets.darwin-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-darwin-arm64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.10/opencode-darwin-arm64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-x86_64]
|
[agent_servers.opencode.targets.darwin-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-darwin-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.10/opencode-darwin-x64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-aarch64]
|
[agent_servers.opencode.targets.linux-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-linux-arm64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.10/opencode-linux-arm64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-x86_64]
|
[agent_servers.opencode.targets.linux-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-linux-x64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.10/opencode-linux-x64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.windows-x86_64]
|
[agent_servers.opencode.targets.windows-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-windows-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.10/opencode-windows-x64.zip"
|
||||||
cmd = "./opencode.exe"
|
cmd = "./opencode.exe"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -102,8 +102,8 @@
|
|||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "2.3.3",
|
"@openrouter/ai-sdk-provider": "2.3.3",
|
||||||
"@opentui/core": "0.1.95",
|
"@opentui/core": "0.1.93",
|
||||||
"@opentui/solid": "0.1.95",
|
"@opentui/solid": "0.1.93",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
||||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
|
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
@@ -175,8 +175,9 @@ export namespace Account {
|
|||||||
mapAccountServiceError("HTTP request failed"),
|
mapAccountServiceError("HTTP request failed"),
|
||||||
)
|
)
|
||||||
|
|
||||||
const refreshToken = Effect.fnUntraced(function* (row: AccountRow) {
|
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||||
const now = yield* Clock.currentTimeMillis
|
const now = yield* Clock.currentTimeMillis
|
||||||
|
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
||||||
|
|
||||||
const response = yield* executeEffectOk(
|
const response = yield* executeEffectOk(
|
||||||
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
|
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
|
||||||
@@ -207,30 +208,6 @@ export namespace Account {
|
|||||||
return parsed.access_token
|
return parsed.access_token
|
||||||
})
|
})
|
||||||
|
|
||||||
const refreshTokenCache = yield* Cache.make<AccountID, AccessToken, AccountError>({
|
|
||||||
capacity: Number.POSITIVE_INFINITY,
|
|
||||||
timeToLive: Duration.zero,
|
|
||||||
lookup: Effect.fnUntraced(function* (accountID) {
|
|
||||||
const maybeAccount = yield* repo.getRow(accountID)
|
|
||||||
if (Option.isNone(maybeAccount)) {
|
|
||||||
return yield* Effect.fail(new AccountServiceError({ message: "Account not found during token refresh" }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const account = maybeAccount.value
|
|
||||||
const now = yield* Clock.currentTimeMillis
|
|
||||||
if (account.token_expiry && account.token_expiry > now) return account.access_token
|
|
||||||
|
|
||||||
return yield* refreshToken(account)
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
|
||||||
const now = yield* Clock.currentTimeMillis
|
|
||||||
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
|
||||||
|
|
||||||
return yield* Cache.get(refreshTokenCache, row.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
|
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
|
||||||
const maybeAccount = yield* repo.getRow(accountID)
|
const maybeAccount = yield* repo.getRow(accountID)
|
||||||
if (Option.isNone(maybeAccount)) return Option.none()
|
if (Option.isNone(maybeAccount)) return Option.none()
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ export namespace Agent {
|
|||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const auth = yield* Auth.Service
|
const auth = yield* Auth.Service
|
||||||
const skill = yield* Skill.Service
|
const skill = yield* Skill.Service
|
||||||
const provider = yield* Provider.Service
|
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
const state = yield* InstanceState.make<State>(
|
||||||
Effect.fn("Agent.state")(function* (ctx) {
|
Effect.fn("Agent.state")(function* (ctx) {
|
||||||
@@ -331,9 +330,9 @@ export namespace Agent {
|
|||||||
model?: { providerID: ProviderID; modelID: ModelID }
|
model?: { providerID: ProviderID; modelID: ModelID }
|
||||||
}) {
|
}) {
|
||||||
const cfg = yield* config.get()
|
const cfg = yield* config.get()
|
||||||
const model = input.model ?? (yield* provider.defaultModel())
|
const model = input.model ?? (yield* Effect.promise(() => Provider.defaultModel()))
|
||||||
const resolved = yield* provider.getModel(model.providerID, model.modelID)
|
const resolved = yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID))
|
||||||
const language = yield* provider.getLanguage(resolved)
|
const language = yield* Effect.promise(() => Provider.getLanguage(resolved))
|
||||||
|
|
||||||
const system = [PROMPT_GENERATE]
|
const system = [PROMPT_GENERATE]
|
||||||
yield* Effect.promise(() =>
|
yield* Effect.promise(() =>
|
||||||
@@ -394,7 +393,6 @@ export namespace Agent {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(Provider.defaultLayer),
|
|
||||||
Layer.provide(Auth.defaultLayer),
|
Layer.provide(Auth.defaultLayer),
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Skill.defaultLayer),
|
Layer.provide(Skill.defaultLayer),
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export namespace Bus {
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const state = yield* InstanceState.make<State>(
|
const cache = yield* InstanceState.make<State>(
|
||||||
Effect.fn("Bus.state")(function* (ctx) {
|
Effect.fn("Bus.state")(function* (ctx) {
|
||||||
const wildcard = yield* PubSub.unbounded<Payload>()
|
const wildcard = yield* PubSub.unbounded<Payload>()
|
||||||
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
||||||
@@ -82,17 +82,16 @@ export namespace Bus {
|
|||||||
|
|
||||||
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const s = yield* InstanceState.get(state)
|
const state = yield* InstanceState.get(cache)
|
||||||
const payload: Payload = { type: def.type, properties }
|
const payload: Payload = { type: def.type, properties }
|
||||||
log.info("publishing", { type: def.type })
|
log.info("publishing", { type: def.type })
|
||||||
|
|
||||||
const ps = s.typed.get(def.type)
|
const ps = state.typed.get(def.type)
|
||||||
if (ps) yield* PubSub.publish(ps, payload)
|
if (ps) yield* PubSub.publish(ps, payload)
|
||||||
yield* PubSub.publish(s.wildcard, payload)
|
yield* PubSub.publish(state.wildcard, payload)
|
||||||
|
|
||||||
const dir = yield* InstanceState.directory
|
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
directory: dir,
|
directory: Instance.directory,
|
||||||
payload,
|
payload,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -102,8 +101,8 @@ export namespace Bus {
|
|||||||
log.info("subscribing", { type: def.type })
|
log.info("subscribing", { type: def.type })
|
||||||
return Stream.unwrap(
|
return Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* InstanceState.get(state)
|
const state = yield* InstanceState.get(cache)
|
||||||
const ps = yield* getOrCreate(s, def)
|
const ps = yield* getOrCreate(state, def)
|
||||||
return Stream.fromPubSub(ps)
|
return Stream.fromPubSub(ps)
|
||||||
}),
|
}),
|
||||||
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
|
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
|
||||||
@@ -113,8 +112,8 @@ export namespace Bus {
|
|||||||
log.info("subscribing", { type: "*" })
|
log.info("subscribing", { type: "*" })
|
||||||
return Stream.unwrap(
|
return Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* InstanceState.get(state)
|
const state = yield* InstanceState.get(cache)
|
||||||
return Stream.fromPubSub(s.wildcard)
|
return Stream.fromPubSub(state.wildcard)
|
||||||
}),
|
}),
|
||||||
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
|
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
|
||||||
}
|
}
|
||||||
@@ -150,14 +149,14 @@ export namespace Bus {
|
|||||||
def: D,
|
def: D,
|
||||||
callback: (event: Payload<D>) => unknown,
|
callback: (event: Payload<D>) => unknown,
|
||||||
) {
|
) {
|
||||||
const s = yield* InstanceState.get(state)
|
const state = yield* InstanceState.get(cache)
|
||||||
const ps = yield* getOrCreate(s, def)
|
const ps = yield* getOrCreate(state, def)
|
||||||
return yield* on(ps, def.type, callback)
|
return yield* on(ps, def.type, callback)
|
||||||
})
|
})
|
||||||
|
|
||||||
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
|
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
|
||||||
const s = yield* InstanceState.get(state)
|
const state = yield* InstanceState.get(cache)
|
||||||
return yield* on(s.wildcard, "*", callback)
|
return yield* on(state.wildcard, "*", callback)
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
|
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
return agents()
|
return agents()
|
||||||
},
|
},
|
||||||
current() {
|
current() {
|
||||||
return agents().find((x) => x.name === agentStore.current) ?? agents()[0]
|
return agents().find((x) => x.name === agentStore.current)!
|
||||||
},
|
},
|
||||||
set(name: string) {
|
set(name: string) {
|
||||||
if (!agents().some((x) => x.name === name))
|
if (!agents().some((x) => x.name === name))
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export namespace Command {
|
|||||||
|
|
||||||
commands[Default.INIT] = {
|
commands[Default.INIT] = {
|
||||||
name: Default.INIT,
|
name: Default.INIT,
|
||||||
description: "guided AGENTS.md setup",
|
description: "create/update AGENTS.md",
|
||||||
source: "command",
|
source: "command",
|
||||||
get template() {
|
get template() {
|
||||||
return PROMPT_INITIALIZE.replace("${path}", ctx.worktree)
|
return PROMPT_INITIALIZE.replace("${path}", ctx.worktree)
|
||||||
@@ -161,16 +161,16 @@ export namespace Command {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>((ctx) => init(ctx))
|
const cache = yield* InstanceState.make<State>((ctx) => init(ctx))
|
||||||
|
|
||||||
const get = Effect.fn("Command.get")(function* (name: string) {
|
const get = Effect.fn("Command.get")(function* (name: string) {
|
||||||
const s = yield* InstanceState.get(state)
|
const state = yield* InstanceState.get(cache)
|
||||||
return s.commands[name]
|
return state.commands[name]
|
||||||
})
|
})
|
||||||
|
|
||||||
const list = Effect.fn("Command.list")(function* () {
|
const list = Effect.fn("Command.list")(function* () {
|
||||||
const s = yield* InstanceState.get(state)
|
const state = yield* InstanceState.get(cache)
|
||||||
return Object.values(s.commands)
|
return Object.values(state.commands)
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ get, list })
|
return Service.of({ get, list })
|
||||||
|
|||||||
@@ -1,66 +1,10 @@
|
|||||||
Create or update `AGENTS.md` for this repository.
|
Please analyze this codebase and create an AGENTS.md file containing:
|
||||||
|
1. Build/lint/test commands - especially for running a single test
|
||||||
|
2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
|
||||||
|
|
||||||
The goal is a compact instruction file that helps future OpenCode sessions avoid mistakes and ramp up quickly. Every line should answer: "Would an agent likely miss this without help?" If not, leave it out.
|
The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 150 lines long.
|
||||||
|
If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.
|
||||||
|
|
||||||
|
If there's already an AGENTS.md, improve it if it's located in ${path}
|
||||||
|
|
||||||
User-provided focus or constraints (honor these):
|
|
||||||
$ARGUMENTS
|
$ARGUMENTS
|
||||||
|
|
||||||
## How to investigate
|
|
||||||
|
|
||||||
Read the highest-value sources first:
|
|
||||||
- `README*`, root manifests, workspace config, lockfiles
|
|
||||||
- build, test, lint, formatter, typecheck, and codegen config
|
|
||||||
- CI workflows and pre-commit / task runner config
|
|
||||||
- existing instruction files (`AGENTS.md`, `CLAUDE.md`, `.cursor/rules/`, `.cursorrules`, `.github/copilot-instructions.md`)
|
|
||||||
- repo-local OpenCode config such as `opencode.json`
|
|
||||||
|
|
||||||
If architecture is still unclear after reading config and docs, inspect a small number of representative code files to find the real entrypoints, package boundaries, and execution flow. Prefer reading the files that explain how the system is wired together over random leaf files.
|
|
||||||
|
|
||||||
Prefer executable sources of truth over prose. If docs conflict with config or scripts, trust the executable source and only keep what you can verify.
|
|
||||||
|
|
||||||
## What to extract
|
|
||||||
|
|
||||||
Look for the highest-signal facts for an agent working in this repo:
|
|
||||||
- exact developer commands, especially non-obvious ones
|
|
||||||
- how to run a single test, a single package, or a focused verification step
|
|
||||||
- required command order when it matters, such as `lint -> typecheck -> test`
|
|
||||||
- monorepo or multi-package boundaries, ownership of major directories, and the real app/library entrypoints
|
|
||||||
- framework or toolchain quirks: generated code, migrations, codegen, build artifacts, special env loading, dev servers, infra deploy flow
|
|
||||||
- repo-specific style or workflow conventions that differ from defaults
|
|
||||||
- testing quirks: fixtures, integration test prerequisites, snapshot workflows, required services, flaky or expensive suites
|
|
||||||
- important constraints from existing instruction files worth preserving
|
|
||||||
|
|
||||||
Good `AGENTS.md` content is usually hard-earned context that took reading multiple files to infer.
|
|
||||||
|
|
||||||
## Questions
|
|
||||||
|
|
||||||
Only ask the user questions if the repo cannot answer something important. Use the `question` tool for one short batch at most.
|
|
||||||
|
|
||||||
Good questions:
|
|
||||||
- undocumented team conventions
|
|
||||||
- branch / PR / release expectations
|
|
||||||
- missing setup or test prerequisites that are known but not written down
|
|
||||||
|
|
||||||
Do not ask about anything the repo already makes clear.
|
|
||||||
|
|
||||||
## Writing rules
|
|
||||||
|
|
||||||
Include only high-signal, repo-specific guidance such as:
|
|
||||||
- exact commands and shortcuts the agent would otherwise guess wrong
|
|
||||||
- architecture notes that are not obvious from filenames
|
|
||||||
- conventions that differ from language or framework defaults
|
|
||||||
- setup requirements, environment quirks, and operational gotchas
|
|
||||||
- references to existing instruction sources that matter
|
|
||||||
|
|
||||||
Exclude:
|
|
||||||
- generic software advice
|
|
||||||
- long tutorials or exhaustive file trees
|
|
||||||
- obvious language conventions
|
|
||||||
- speculative claims or anything you could not verify
|
|
||||||
- content better stored in another file referenced via `opencode.json` `instructions`
|
|
||||||
|
|
||||||
When in doubt, omit.
|
|
||||||
|
|
||||||
Prefer short sections and bullets. If the repo is simple, keep the file simple. If the repo is large, summarize the few structural facts that actually change how an agent should work.
|
|
||||||
|
|
||||||
If `AGENTS.md` already exists at `${path}`, improve it in place rather than rewriting blindly. Preserve verified useful guidance, delete fluff or stale claims, and reconcile it with the current codebase.
|
|
||||||
|
|||||||
@@ -1486,8 +1486,7 @@ export namespace Config {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const update = Effect.fn("Config.update")(function* (config: Info) {
|
const update = Effect.fn("Config.update")(function* (config: Info) {
|
||||||
const dir = yield* InstanceState.directory
|
const file = path.join(Instance.directory, "config.json")
|
||||||
const file = path.join(dir, "config.json")
|
|
||||||
const existing = yield* loadFile(file)
|
const existing = yield* loadFile(file)
|
||||||
yield* fs.writeFileString(file, JSON.stringify(mergeDeep(existing, config), null, 2)).pipe(Effect.orDie)
|
yield* fs.writeFileString(file, JSON.stringify(mergeDeep(existing, config), null, 2)).pipe(Effect.orDie)
|
||||||
yield* Effect.promise(() => Instance.dispose())
|
yield* Effect.promise(() => Instance.dispose())
|
||||||
|
|||||||
@@ -386,17 +386,9 @@ export const make = Effect.gen(function* () {
|
|||||||
if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup))
|
if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup))
|
||||||
return yield* Effect.void
|
return yield* Effect.void
|
||||||
}
|
}
|
||||||
const send = (s: NodeJS.Signals) =>
|
return yield* kill((command, proc, signal) =>
|
||||||
Effect.catch(killGroup(command, proc, s), () => killOne(command, proc, s))
|
Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)),
|
||||||
const sig = command.options.killSignal ?? "SIGTERM"
|
).pipe(Effect.andThen(Deferred.await(signal)), Effect.ignore)
|
||||||
const attempt = send(sig).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid)
|
|
||||||
const escalated = command.options.forceKillAfter
|
|
||||||
? Effect.timeoutOrElse(attempt, {
|
|
||||||
duration: command.options.forceKillAfter,
|
|
||||||
orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
|
|
||||||
})
|
|
||||||
: attempt
|
|
||||||
return yield* Effect.ignore(escalated)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -421,17 +413,14 @@ export const make = Effect.gen(function* () {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
kill: (opts?: ChildProcess.KillOptions) => {
|
kill: (opts?: ChildProcess.KillOptions) =>
|
||||||
const sig = opts?.killSignal ?? "SIGTERM"
|
timeout(
|
||||||
const send = (s: NodeJS.Signals) =>
|
proc,
|
||||||
Effect.catch(killGroup(command, proc, s), () => killOne(command, proc, s))
|
command,
|
||||||
const attempt = send(sig).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid)
|
opts,
|
||||||
if (!opts?.forceKillAfter) return attempt
|
)((command, proc, signal) =>
|
||||||
return Effect.timeoutOrElse(attempt, {
|
Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)),
|
||||||
duration: opts.forceKillAfter,
|
).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
|
||||||
orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
case "PipedCommand": {
|
case "PipedCommand": {
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { ServiceMap } from "effect"
|
|
||||||
import type { InstanceContext } from "@/project/instance"
|
|
||||||
|
|
||||||
export const InstanceRef = ServiceMap.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
|
|
||||||
defaultValue: () => undefined,
|
|
||||||
})
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect"
|
import { Effect, ScopedCache, Scope } from "effect"
|
||||||
import { Instance, type InstanceContext } from "@/project/instance"
|
import { Instance, type InstanceContext } from "@/project/instance"
|
||||||
import { Context } from "@/util/context"
|
|
||||||
import { InstanceRef } from "./instance-ref"
|
|
||||||
import { registerDisposer } from "./instance-registry"
|
import { registerDisposer } from "./instance-registry"
|
||||||
|
|
||||||
const TypeId = "~opencode/InstanceState"
|
const TypeId = "~opencode/InstanceState"
|
||||||
@@ -12,34 +10,13 @@ export interface InstanceState<A, E = never, R = never> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export namespace InstanceState {
|
export namespace InstanceState {
|
||||||
export const bind = <F extends (...args: any[]) => any>(fn: F): F => {
|
|
||||||
try {
|
|
||||||
return Instance.bind(fn)
|
|
||||||
} catch (err) {
|
|
||||||
if (!(err instanceof Context.NotFound)) throw err
|
|
||||||
}
|
|
||||||
const fiber = Fiber.getCurrent()
|
|
||||||
const ctx = fiber ? ServiceMap.getReferenceUnsafe(fiber.services, InstanceRef) : undefined
|
|
||||||
if (!ctx) return fn
|
|
||||||
return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F
|
|
||||||
}
|
|
||||||
|
|
||||||
export const context = Effect.gen(function* () {
|
|
||||||
return (yield* InstanceRef) ?? Instance.current
|
|
||||||
})
|
|
||||||
|
|
||||||
export const directory = Effect.map(context, (ctx) => ctx.directory)
|
|
||||||
|
|
||||||
export const make = <A, E = never, R = never>(
|
export const make = <A, E = never, R = never>(
|
||||||
init: (ctx: InstanceContext) => Effect.Effect<A, E, R | Scope.Scope>,
|
init: (ctx: InstanceContext) => Effect.Effect<A, E, R | Scope.Scope>,
|
||||||
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
|
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const cache = yield* ScopedCache.make<string, A, E, R>({
|
const cache = yield* ScopedCache.make<string, A, E, R>({
|
||||||
capacity: Number.POSITIVE_INFINITY,
|
capacity: Number.POSITIVE_INFINITY,
|
||||||
lookup: () =>
|
lookup: () => init(Instance.current),
|
||||||
Effect.gen(function* () {
|
|
||||||
return yield* init(yield* context)
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
|
const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
|
||||||
@@ -52,9 +29,7 @@ export namespace InstanceState {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const get = <A, E, R>(self: InstanceState<A, E, R>) =>
|
export const get = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||||
Effect.gen(function* () {
|
Effect.suspend(() => ScopedCache.get(self.cache, Instance.directory))
|
||||||
return yield* ScopedCache.get(self.cache, yield* directory)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const use = <A, E, R, B>(self: InstanceState<A, E, R>, select: (value: A) => B) =>
|
export const use = <A, E, R, B>(self: InstanceState<A, E, R>, select: (value: A) => B) =>
|
||||||
Effect.map(get(self), select)
|
Effect.map(get(self), select)
|
||||||
@@ -65,18 +40,8 @@ export namespace InstanceState {
|
|||||||
) => Effect.flatMap(get(self), select)
|
) => Effect.flatMap(get(self), select)
|
||||||
|
|
||||||
export const has = <A, E, R>(self: InstanceState<A, E, R>) =>
|
export const has = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||||
Effect.gen(function* () {
|
Effect.suspend(() => ScopedCache.has(self.cache, Instance.directory))
|
||||||
return yield* ScopedCache.has(self.cache, yield* directory)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
|
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||||
Effect.gen(function* () {
|
Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory))
|
||||||
return yield* ScopedCache.invalidate(self.cache, yield* directory)
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Effect finalizers run on the fiber scheduler after the original async
|
|
||||||
* boundary, so ALS reads like Instance.directory can be gone by then.
|
|
||||||
*/
|
|
||||||
export const withALS = <T>(fn: () => T) => Effect.map(context, (ctx) => Instance.restore(ctx, fn))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,19 @@
|
|||||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||||
import * as ServiceMap from "effect/ServiceMap"
|
import * as ServiceMap from "effect/ServiceMap"
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
import { Context } from "@/util/context"
|
|
||||||
import { InstanceRef } from "./instance-ref"
|
|
||||||
|
|
||||||
export const memoMap = Layer.makeMemoMapUnsafe()
|
export const memoMap = Layer.makeMemoMapUnsafe()
|
||||||
|
|
||||||
function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
|
|
||||||
try {
|
|
||||||
const ctx = Instance.current
|
|
||||||
return Effect.provideService(effect, InstanceRef, ctx)
|
|
||||||
} catch (err) {
|
|
||||||
if (!(err instanceof Context.NotFound)) throw err
|
|
||||||
}
|
|
||||||
return effect
|
|
||||||
}
|
|
||||||
|
|
||||||
export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
|
export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
|
||||||
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
|
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
|
||||||
const getRuntime = () => (rt ??= ManagedRuntime.make(layer, { memoMap }))
|
const getRuntime = () => (rt ??= ManagedRuntime.make(layer, { memoMap }))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(attach(service.use(fn))),
|
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
|
||||||
runPromiseExit: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
|
runPromiseExit: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
|
||||||
getRuntime().runPromiseExit(attach(service.use(fn)), options),
|
getRuntime().runPromiseExit(service.use(fn), options),
|
||||||
runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
|
runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
|
||||||
getRuntime().runPromise(attach(service.use(fn)), options),
|
getRuntime().runPromise(service.use(fn), options),
|
||||||
runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(attach(service.use(fn))),
|
runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)),
|
||||||
runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) =>
|
runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runCallback(service.use(fn)),
|
||||||
getRuntime().runCallback(attach(service.use(fn))),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AppFileSystem } from "@/filesystem"
|
|||||||
import { git } from "@/util/git"
|
import { git } from "@/util/git"
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import { formatPatch, structuredPatch } from "diff"
|
import { formatPatch, structuredPatch } from "diff"
|
||||||
|
import fs from "fs"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import ignore from "ignore"
|
import ignore from "ignore"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
@@ -358,46 +359,49 @@ export namespace File {
|
|||||||
const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
|
const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
|
||||||
const next: Entry = { files: [], dirs: [] }
|
const next: Entry = { files: [], dirs: [] }
|
||||||
|
|
||||||
if (isGlobalHome) {
|
yield* Effect.promise(async () => {
|
||||||
const dirs = new Set<string>()
|
if (isGlobalHome) {
|
||||||
const protectedNames = Protected.names()
|
const dirs = new Set<string>()
|
||||||
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
const protectedNames = Protected.names()
|
||||||
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
|
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
||||||
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
|
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
|
||||||
const top = yield* appFs.readDirectoryEntries(Instance.directory).pipe(Effect.orElseSucceed(() => []))
|
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
|
||||||
|
const top = await fs.promises
|
||||||
|
.readdir(Instance.directory, { withFileTypes: true })
|
||||||
|
.catch(() => [] as fs.Dirent[])
|
||||||
|
|
||||||
for (const entry of top) {
|
for (const entry of top) {
|
||||||
if (entry.type !== "directory") continue
|
if (!entry.isDirectory()) continue
|
||||||
if (shouldIgnoreName(entry.name)) continue
|
if (shouldIgnoreName(entry.name)) continue
|
||||||
dirs.add(entry.name + "/")
|
dirs.add(entry.name + "/")
|
||||||
|
|
||||||
const base = path.join(Instance.directory, entry.name)
|
const base = path.join(Instance.directory, entry.name)
|
||||||
const children = yield* appFs.readDirectoryEntries(base).pipe(Effect.orElseSucceed(() => []))
|
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
if (child.type !== "directory") continue
|
if (!child.isDirectory()) continue
|
||||||
if (shouldIgnoreNested(child.name)) continue
|
if (shouldIgnoreNested(child.name)) continue
|
||||||
dirs.add(entry.name + "/" + child.name + "/")
|
dirs.add(entry.name + "/" + child.name + "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next.dirs = Array.from(dirs).toSorted()
|
||||||
|
} else {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for await (const file of Ripgrep.files({ cwd: Instance.directory })) {
|
||||||
|
next.files.push(file)
|
||||||
|
let current = file
|
||||||
|
while (true) {
|
||||||
|
const dir = path.dirname(current)
|
||||||
|
if (dir === ".") break
|
||||||
|
if (dir === current) break
|
||||||
|
current = dir
|
||||||
|
if (seen.has(dir)) continue
|
||||||
|
seen.add(dir)
|
||||||
|
next.dirs.push(dir + "/")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
next.dirs = Array.from(dirs).toSorted()
|
|
||||||
} else {
|
|
||||||
const files = yield* Effect.promise(() => Array.fromAsync(Ripgrep.files({ cwd: Instance.directory })))
|
|
||||||
const seen = new Set<string>()
|
|
||||||
for (const file of files) {
|
|
||||||
next.files.push(file)
|
|
||||||
let current = file
|
|
||||||
while (true) {
|
|
||||||
const dir = path.dirname(current)
|
|
||||||
if (dir === ".") break
|
|
||||||
if (dir === current) break
|
|
||||||
current = dir
|
|
||||||
if (seen.has(dir)) continue
|
|
||||||
seen.add(dir)
|
|
||||||
next.dirs.push(dir + "/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(state)
|
||||||
s.cache = next
|
s.cache = next
|
||||||
@@ -632,27 +636,30 @@ export namespace File {
|
|||||||
yield* ensure()
|
yield* ensure()
|
||||||
const { cache } = yield* InstanceState.get(state)
|
const { cache } = yield* InstanceState.get(state)
|
||||||
|
|
||||||
const query = input.query.trim()
|
return yield* Effect.promise(async () => {
|
||||||
const limit = input.limit ?? 100
|
const query = input.query.trim()
|
||||||
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
const limit = input.limit ?? 100
|
||||||
log.info("search", { query, kind })
|
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
||||||
|
log.info("search", { query, kind })
|
||||||
|
|
||||||
const preferHidden = query.startsWith(".") || query.includes("/.")
|
const result = cache
|
||||||
|
const preferHidden = query.startsWith(".") || query.includes("/.")
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
if (kind === "file") return cache.files.slice(0, limit)
|
if (kind === "file") return result.files.slice(0, limit)
|
||||||
return sortHiddenLast(cache.dirs.toSorted(), preferHidden).slice(0, limit)
|
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
const items =
|
const items =
|
||||||
kind === "file" ? cache.files : kind === "directory" ? cache.dirs : [...cache.files, ...cache.dirs]
|
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
|
||||||
|
|
||||||
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
|
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
|
||||||
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
|
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
|
||||||
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
|
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
|
||||||
|
|
||||||
log.info("search", { query, kind, results: output.length })
|
log.info("search", { query, kind, results: output.length })
|
||||||
return output
|
return output
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
log.info("init")
|
log.info("init")
|
||||||
|
|||||||
@@ -108,11 +108,10 @@ export namespace Format {
|
|||||||
for (const item of yield* Effect.promise(() => getFormatter(ext))) {
|
for (const item of yield* Effect.promise(() => getFormatter(ext))) {
|
||||||
log.info("running", { command: item.command })
|
log.info("running", { command: item.command })
|
||||||
const cmd = item.command.map((x) => x.replace("$FILE", filepath))
|
const cmd = item.command.map((x) => x.replace("$FILE", filepath))
|
||||||
const dir = yield* InstanceState.directory
|
|
||||||
const code = yield* spawner
|
const code = yield* spawner
|
||||||
.spawn(
|
.spawn(
|
||||||
ChildProcess.make(cmd[0]!, cmd.slice(1), {
|
ChildProcess.make(cmd[0]!, cmd.slice(1), {
|
||||||
cwd: dir,
|
cwd: Instance.directory,
|
||||||
env: item.environment,
|
env: item.environment,
|
||||||
extendEnv: true,
|
extendEnv: true,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import z from "zod"
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { CHANNEL as channel, VERSION as version } from "./meta"
|
|
||||||
|
declare global {
|
||||||
|
const OPENCODE_VERSION: string
|
||||||
|
const OPENCODE_CHANNEL: string
|
||||||
|
}
|
||||||
|
|
||||||
import semver from "semver"
|
import semver from "semver"
|
||||||
|
|
||||||
@@ -56,8 +60,8 @@ export namespace Installation {
|
|||||||
})
|
})
|
||||||
export type Info = z.infer<typeof Info>
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
export const VERSION = version
|
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
||||||
export const CHANNEL = channel
|
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
|
||||||
export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
|
export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
|
||||||
|
|
||||||
export function isPreview() {
|
export function isPreview() {
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
declare global {
|
|
||||||
const OPENCODE_VERSION: string
|
|
||||||
const OPENCODE_CHANNEL: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
|
||||||
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
|
|
||||||
@@ -477,7 +477,7 @@ export namespace MCP {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
const cache = yield* InstanceState.make<State>(
|
||||||
Effect.fn("MCP.state")(function* () {
|
Effect.fn("MCP.state")(function* () {
|
||||||
const cfg = yield* cfgSvc.get()
|
const cfg = yield* cfgSvc.get()
|
||||||
const config = cfg.mcp ?? {}
|
const config = cfg.mcp ?? {}
|
||||||
@@ -549,7 +549,7 @@ export namespace MCP {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const status = Effect.fn("MCP.status")(function* () {
|
const status = Effect.fn("MCP.status")(function* () {
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(cache)
|
||||||
|
|
||||||
const cfg = yield* cfgSvc.get()
|
const cfg = yield* cfgSvc.get()
|
||||||
const config = cfg.mcp ?? {}
|
const config = cfg.mcp ?? {}
|
||||||
@@ -564,12 +564,12 @@ export namespace MCP {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const clients = Effect.fn("MCP.clients")(function* () {
|
const clients = Effect.fn("MCP.clients")(function* () {
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(cache)
|
||||||
return s.clients
|
return s.clients
|
||||||
})
|
})
|
||||||
|
|
||||||
const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: Config.Mcp) {
|
const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: Config.Mcp) {
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(cache)
|
||||||
const result = yield* create(name, mcp)
|
const result = yield* create(name, mcp)
|
||||||
|
|
||||||
s.status[name] = result.status
|
s.status[name] = result.status
|
||||||
@@ -588,7 +588,7 @@ export namespace MCP {
|
|||||||
|
|
||||||
const add = Effect.fn("MCP.add")(function* (name: string, mcp: Config.Mcp) {
|
const add = Effect.fn("MCP.add")(function* (name: string, mcp: Config.Mcp) {
|
||||||
yield* createAndStore(name, mcp)
|
yield* createAndStore(name, mcp)
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(cache)
|
||||||
return { status: s.status }
|
return { status: s.status }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -602,7 +602,7 @@ export namespace MCP {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const disconnect = Effect.fn("MCP.disconnect")(function* (name: string) {
|
const disconnect = Effect.fn("MCP.disconnect")(function* (name: string) {
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(cache)
|
||||||
yield* closeClient(s, name)
|
yield* closeClient(s, name)
|
||||||
delete s.clients[name]
|
delete s.clients[name]
|
||||||
s.status[name] = { status: "disabled" }
|
s.status[name] = { status: "disabled" }
|
||||||
@@ -610,7 +610,7 @@ export namespace MCP {
|
|||||||
|
|
||||||
const tools = Effect.fn("MCP.tools")(function* () {
|
const tools = Effect.fn("MCP.tools")(function* () {
|
||||||
const result: Record<string, Tool> = {}
|
const result: Record<string, Tool> = {}
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(cache)
|
||||||
|
|
||||||
const cfg = yield* cfgSvc.get()
|
const cfg = yield* cfgSvc.get()
|
||||||
const config = cfg.mcp ?? {}
|
const config = cfg.mcp ?? {}
|
||||||
@@ -657,12 +657,12 @@ export namespace MCP {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const prompts = Effect.fn("MCP.prompts")(function* () {
|
const prompts = Effect.fn("MCP.prompts")(function* () {
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(cache)
|
||||||
return yield* collectFromConnected(s, (c) => c.listPrompts().then((r) => r.prompts), "prompts")
|
return yield* collectFromConnected(s, (c) => c.listPrompts().then((r) => r.prompts), "prompts")
|
||||||
})
|
})
|
||||||
|
|
||||||
const resources = Effect.fn("MCP.resources")(function* () {
|
const resources = Effect.fn("MCP.resources")(function* () {
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(cache)
|
||||||
return yield* collectFromConnected(s, (c) => c.listResources().then((r) => r.resources), "resources")
|
return yield* collectFromConnected(s, (c) => c.listResources().then((r) => r.resources), "resources")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -672,7 +672,7 @@ export namespace MCP {
|
|||||||
label: string,
|
label: string,
|
||||||
meta?: Record<string, unknown>,
|
meta?: Record<string, unknown>,
|
||||||
) {
|
) {
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(cache)
|
||||||
const client = s.clients[clientName]
|
const client = s.clients[clientName]
|
||||||
if (!client) {
|
if (!client) {
|
||||||
log.warn(`client not found for ${label}`, { clientName })
|
log.warn(`client not found for ${label}`, { clientName })
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export namespace Plugin {
|
|||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
const cache = yield* InstanceState.make<State>(
|
||||||
Effect.fn("Plugin.state")(function* (ctx) {
|
Effect.fn("Plugin.state")(function* (ctx) {
|
||||||
const hooks: Hooks[] = []
|
const hooks: Hooks[] = []
|
||||||
|
|
||||||
@@ -279,8 +279,8 @@ export namespace Plugin {
|
|||||||
Output = Parameters<Required<Hooks>[Name]>[1],
|
Output = Parameters<Required<Hooks>[Name]>[1],
|
||||||
>(name: Name, input: Input, output: Output) {
|
>(name: Name, input: Input, output: Output) {
|
||||||
if (!name) return output
|
if (!name) return output
|
||||||
const s = yield* InstanceState.get(state)
|
const state = yield* InstanceState.get(cache)
|
||||||
for (const hook of s.hooks) {
|
for (const hook of state.hooks) {
|
||||||
const fn = hook[name] as any
|
const fn = hook[name] as any
|
||||||
if (!fn) continue
|
if (!fn) continue
|
||||||
yield* Effect.promise(async () => fn(input, output))
|
yield* Effect.promise(async () => fn(input, output))
|
||||||
@@ -289,12 +289,12 @@ export namespace Plugin {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const list = Effect.fn("Plugin.list")(function* () {
|
const list = Effect.fn("Plugin.list")(function* () {
|
||||||
const s = yield* InstanceState.get(state)
|
const state = yield* InstanceState.get(cache)
|
||||||
return s.hooks
|
return state.hooks
|
||||||
})
|
})
|
||||||
|
|
||||||
const init = Effect.fn("Plugin.init")(function* () {
|
const init = Effect.fn("Plugin.init")(function* () {
|
||||||
yield* InstanceState.get(state)
|
yield* InstanceState.get(cache)
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ trigger, list, init })
|
return Service.of({ trigger, list, init })
|
||||||
|
|||||||
@@ -114,14 +114,6 @@ export const Instance = {
|
|||||||
const ctx = context.use()
|
const ctx = context.use()
|
||||||
return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F
|
return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* Run a synchronous function within the given instance context ALS.
|
|
||||||
* Use this to bridge from Effect (where InstanceRef carries context)
|
|
||||||
* back to sync code that reads Instance.directory from ALS.
|
|
||||||
*/
|
|
||||||
restore<R>(ctx: InstanceContext, fn: () => R): R {
|
|
||||||
return context.provide(ctx, fn)
|
|
||||||
},
|
|
||||||
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
|
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
|
||||||
return State.create(() => Instance.directory, init, dispose)
|
return State.create(() => Instance.directory, init, dispose)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -111,25 +111,26 @@ export namespace ProviderAuth {
|
|||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const auth = yield* Auth.Service
|
const auth = yield* Auth.Service
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
const state = yield* InstanceState.make<State>(
|
const state = yield* InstanceState.make<State>(
|
||||||
Effect.fn("ProviderAuth.state")(function* () {
|
Effect.fn("ProviderAuth.state")(() =>
|
||||||
const plugins = yield* plugin.list()
|
Effect.promise(async () => {
|
||||||
return {
|
const plugins = await Plugin.list()
|
||||||
hooks: Record.fromEntries(
|
return {
|
||||||
Arr.filterMap(plugins, (x) =>
|
hooks: Record.fromEntries(
|
||||||
x.auth?.provider !== undefined
|
Arr.filterMap(plugins, (x) =>
|
||||||
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
|
x.auth?.provider !== undefined
|
||||||
: Result.failVoid,
|
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
|
||||||
|
: Result.failVoid,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
pending: new Map<ProviderID, AuthOAuthResult>(),
|
||||||
pending: new Map<ProviderID, AuthOAuthResult>(),
|
}
|
||||||
}
|
}),
|
||||||
}),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const methods = Effect.fn("ProviderAuth.methods")(function* () {
|
const methods = Effect.fn("ProviderAuth.methods")(function* () {
|
||||||
@@ -229,9 +230,7 @@ export namespace ProviderAuth {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = Layer.suspend(() =>
|
export const defaultLayer = layer.pipe(Layer.provide(Auth.defaultLayer))
|
||||||
layer.pipe(Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
|
|||||||
@@ -961,14 +961,13 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const layer: Layer.Layer<Service, never, Config.Service | Auth.Service | Plugin.Service> = Layer.effect(
|
const layer: Layer.Layer<Service, never, Config.Service | Auth.Service> = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const auth = yield* Auth.Service
|
const auth = yield* Auth.Service
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(() =>
|
const cache = yield* InstanceState.make<State>(() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
using _ = log.time("state")
|
using _ = log.time("state")
|
||||||
const cfg = yield* config.get()
|
const cfg = yield* config.get()
|
||||||
@@ -1129,7 +1128,7 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugins = yield* plugin.list()
|
const plugins = yield* Effect.promise(() => Plugin.list())
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
if (!plugin.auth) continue
|
if (!plugin.auth) continue
|
||||||
const providerID = ProviderID.make(plugin.auth.provider)
|
const providerID = ProviderID.make(plugin.auth.provider)
|
||||||
@@ -1248,7 +1247,7 @@ export namespace Provider {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers))
|
const list = Effect.fn("Provider.list")(() => InstanceState.use(cache, (s) => s.providers))
|
||||||
|
|
||||||
async function resolveSDK(model: Model, s: State) {
|
async function resolveSDK(model: Model, s: State) {
|
||||||
try {
|
try {
|
||||||
@@ -1386,11 +1385,11 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) =>
|
const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) =>
|
||||||
InstanceState.use(state, (s) => s.providers[providerID]),
|
InstanceState.use(cache, (s) => s.providers[providerID]),
|
||||||
)
|
)
|
||||||
|
|
||||||
const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) {
|
const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) {
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(cache)
|
||||||
const provider = s.providers[providerID]
|
const provider = s.providers[providerID]
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
const available = Object.keys(s.providers)
|
const available = Object.keys(s.providers)
|
||||||
@@ -1408,7 +1407,7 @@ export namespace Provider {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) {
|
const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) {
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(cache)
|
||||||
const key = `${model.providerID}/${model.id}`
|
const key = `${model.providerID}/${model.id}`
|
||||||
if (s.models.has(key)) return s.models.get(key)!
|
if (s.models.has(key)) return s.models.get(key)!
|
||||||
|
|
||||||
@@ -1440,7 +1439,7 @@ export namespace Provider {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) {
|
const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) {
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(cache)
|
||||||
const provider = s.providers[providerID]
|
const provider = s.providers[providerID]
|
||||||
if (!provider) return undefined
|
if (!provider) return undefined
|
||||||
for (const item of query) {
|
for (const item of query) {
|
||||||
@@ -1459,7 +1458,7 @@ export namespace Provider {
|
|||||||
return yield* getModel(parsed.providerID, parsed.modelID)
|
return yield* getModel(parsed.providerID, parsed.modelID)
|
||||||
}
|
}
|
||||||
|
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(cache)
|
||||||
const provider = s.providers[providerID]
|
const provider = s.providers[providerID]
|
||||||
if (!provider) return undefined
|
if (!provider) return undefined
|
||||||
|
|
||||||
@@ -1511,7 +1510,7 @@ export namespace Provider {
|
|||||||
const cfg = yield* config.get()
|
const cfg = yield* config.get()
|
||||||
if (cfg.model) return parseModel(cfg.model)
|
if (cfg.model) return parseModel(cfg.model)
|
||||||
|
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(cache)
|
||||||
const recent = yield* Effect.promise(() =>
|
const recent = yield* Effect.promise(() =>
|
||||||
Filesystem.readJson<{
|
Filesystem.readJson<{
|
||||||
recent?: { providerID: ProviderID; modelID: ModelID }[]
|
recent?: { providerID: ProviderID; modelID: ModelID }[]
|
||||||
@@ -1542,16 +1541,11 @@ export namespace Provider {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = Layer.suspend(() =>
|
const { runPromise } = makeRuntime(
|
||||||
layer.pipe(
|
Service,
|
||||||
Layer.provide(Config.defaultLayer),
|
layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Auth.defaultLayer)),
|
||||||
Layer.provide(Auth.defaultLayer),
|
|
||||||
Layer.provide(Plugin.defaultLayer),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function list() {
|
export async function list() {
|
||||||
return runPromise((svc) => svc.list())
|
return runPromise((svc) => svc.list())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,7 +280,6 @@ export namespace ProviderTransform {
|
|||||||
msgs = normalizeMessages(msgs, model, options)
|
msgs = normalizeMessages(msgs, model, options)
|
||||||
if (
|
if (
|
||||||
(model.providerID === "anthropic" ||
|
(model.providerID === "anthropic" ||
|
||||||
model.providerID === "google-vertex-anthropic" ||
|
|
||||||
model.api.id.includes("anthropic") ||
|
model.api.id.includes("anthropic") ||
|
||||||
model.api.id.includes("claude") ||
|
model.api.id.includes("claude") ||
|
||||||
model.id.includes("anthropic") ||
|
model.id.includes("anthropic") ||
|
||||||
@@ -293,7 +292,7 @@ export namespace ProviderTransform {
|
|||||||
|
|
||||||
// Remap providerOptions keys from stored providerID to expected SDK key
|
// Remap providerOptions keys from stored providerID to expected SDK key
|
||||||
const key = sdkKey(model.api.npm)
|
const key = sdkKey(model.api.npm)
|
||||||
if (key && key !== model.providerID) {
|
if (key && key !== model.providerID && model.api.npm !== "@ai-sdk/azure") {
|
||||||
const remap = (opts: Record<string, any> | undefined) => {
|
const remap = (opts: Record<string, any> | undefined) => {
|
||||||
if (!opts) return opts
|
if (!opts) return opts
|
||||||
if (!(model.providerID in opts)) return opts
|
if (!(model.providerID in opts)) return opts
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export namespace Pty {
|
|||||||
session.subscribers.clear()
|
session.subscribers.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
const cache = yield* InstanceState.make<State>(
|
||||||
Effect.fn("Pty.state")(function* (ctx) {
|
Effect.fn("Pty.state")(function* (ctx) {
|
||||||
const state = {
|
const state = {
|
||||||
dir: ctx.directory,
|
dir: ctx.directory,
|
||||||
@@ -151,27 +151,27 @@ export namespace Pty {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
|
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
|
||||||
const s = yield* InstanceState.get(state)
|
const state = yield* InstanceState.get(cache)
|
||||||
const session = s.sessions.get(id)
|
const session = state.sessions.get(id)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
s.sessions.delete(id)
|
state.sessions.delete(id)
|
||||||
log.info("removing session", { id })
|
log.info("removing session", { id })
|
||||||
teardown(session)
|
teardown(session)
|
||||||
void Bus.publish(Event.Deleted, { id: session.info.id })
|
void Bus.publish(Event.Deleted, { id: session.info.id })
|
||||||
})
|
})
|
||||||
|
|
||||||
const list = Effect.fn("Pty.list")(function* () {
|
const list = Effect.fn("Pty.list")(function* () {
|
||||||
const s = yield* InstanceState.get(state)
|
const state = yield* InstanceState.get(cache)
|
||||||
return Array.from(s.sessions.values()).map((session) => session.info)
|
return Array.from(state.sessions.values()).map((session) => session.info)
|
||||||
})
|
})
|
||||||
|
|
||||||
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
|
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
|
||||||
const s = yield* InstanceState.get(state)
|
const state = yield* InstanceState.get(cache)
|
||||||
return s.sessions.get(id)?.info
|
return state.sessions.get(id)?.info
|
||||||
})
|
})
|
||||||
|
|
||||||
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
||||||
const s = yield* InstanceState.get(state)
|
const state = yield* InstanceState.get(cache)
|
||||||
return yield* Effect.promise(async () => {
|
return yield* Effect.promise(async () => {
|
||||||
const id = PtyID.ascending()
|
const id = PtyID.ascending()
|
||||||
const command = input.command || Shell.preferred()
|
const command = input.command || Shell.preferred()
|
||||||
@@ -180,7 +180,7 @@ export namespace Pty {
|
|||||||
args.push("-l")
|
args.push("-l")
|
||||||
}
|
}
|
||||||
|
|
||||||
const cwd = input.cwd || s.dir
|
const cwd = input.cwd || state.dir
|
||||||
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
|
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -221,7 +221,7 @@ export namespace Pty {
|
|||||||
cursor: 0,
|
cursor: 0,
|
||||||
subscribers: new Map(),
|
subscribers: new Map(),
|
||||||
}
|
}
|
||||||
s.sessions.set(id, session)
|
state.sessions.set(id, session)
|
||||||
proc.onData(
|
proc.onData(
|
||||||
Instance.bind((chunk) => {
|
Instance.bind((chunk) => {
|
||||||
session.cursor += chunk.length
|
session.cursor += chunk.length
|
||||||
@@ -264,8 +264,8 @@ export namespace Pty {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
|
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
|
||||||
const s = yield* InstanceState.get(state)
|
const state = yield* InstanceState.get(cache)
|
||||||
const session = s.sessions.get(id)
|
const session = state.sessions.get(id)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
if (input.title) {
|
if (input.title) {
|
||||||
session.info.title = input.title
|
session.info.title = input.title
|
||||||
@@ -278,24 +278,24 @@ export namespace Pty {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
|
const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
|
||||||
const s = yield* InstanceState.get(state)
|
const state = yield* InstanceState.get(cache)
|
||||||
const session = s.sessions.get(id)
|
const session = state.sessions.get(id)
|
||||||
if (session && session.info.status === "running") {
|
if (session && session.info.status === "running") {
|
||||||
session.process.resize(cols, rows)
|
session.process.resize(cols, rows)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
|
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
|
||||||
const s = yield* InstanceState.get(state)
|
const state = yield* InstanceState.get(cache)
|
||||||
const session = s.sessions.get(id)
|
const session = state.sessions.get(id)
|
||||||
if (session && session.info.status === "running") {
|
if (session && session.info.status === "running") {
|
||||||
session.process.write(data)
|
session.process.write(data)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
|
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
|
||||||
const s = yield* InstanceState.get(state)
|
const state = yield* InstanceState.get(cache)
|
||||||
const session = s.sessions.get(id)
|
const session = state.sessions.get(id)
|
||||||
if (!session) {
|
if (!session) {
|
||||||
ws.close()
|
ws.close()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -436,13 +436,13 @@ export const SessionRoutes = lazy(() =>
|
|||||||
validator(
|
validator(
|
||||||
"param",
|
"param",
|
||||||
z.object({
|
z.object({
|
||||||
sessionID: SessionSummary.DiffInput.shape.sessionID,
|
sessionID: SessionSummary.diff.schema.shape.sessionID,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
validator(
|
validator(
|
||||||
"query",
|
"query",
|
||||||
z.object({
|
z.object({
|
||||||
messageID: SessionSummary.DiffInput.shape.messageID,
|
messageID: SessionSummary.diff.schema.shape.messageID,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { NotFoundError } from "@/storage/db"
|
|||||||
import { ModelID, ProviderID } from "@/provider/schema"
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
|
||||||
import { isOverflow as overflow } from "./overflow"
|
import { isOverflow as overflow } from "./overflow"
|
||||||
|
|
||||||
export namespace SessionCompaction {
|
export namespace SessionCompaction {
|
||||||
@@ -63,13 +62,7 @@ export namespace SessionCompaction {
|
|||||||
export const layer: Layer.Layer<
|
export const layer: Layer.Layer<
|
||||||
Service,
|
Service,
|
||||||
never,
|
never,
|
||||||
| Bus.Service
|
Bus.Service | Config.Service | Session.Service | Agent.Service | Plugin.Service | SessionProcessor.Service
|
||||||
| Config.Service
|
|
||||||
| Session.Service
|
|
||||||
| Agent.Service
|
|
||||||
| Plugin.Service
|
|
||||||
| SessionProcessor.Service
|
|
||||||
| Provider.Service
|
|
||||||
> = Layer.effect(
|
> = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -79,7 +72,6 @@ export namespace SessionCompaction {
|
|||||||
const agents = yield* Agent.Service
|
const agents = yield* Agent.Service
|
||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
const processors = yield* SessionProcessor.Service
|
const processors = yield* SessionProcessor.Service
|
||||||
const provider = yield* Provider.Service
|
|
||||||
|
|
||||||
const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: {
|
const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: {
|
||||||
tokens: MessageV2.Assistant["tokens"]
|
tokens: MessageV2.Assistant["tokens"]
|
||||||
@@ -177,9 +169,11 @@ export namespace SessionCompaction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const agent = yield* agents.get("compaction")
|
const agent = yield* agents.get("compaction")
|
||||||
const model = agent.model
|
const model = yield* Effect.promise(() =>
|
||||||
? yield* provider.getModel(agent.model.providerID, agent.model.modelID)
|
agent.model
|
||||||
: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
|
? Provider.getModel(agent.model.providerID, agent.model.modelID)
|
||||||
|
: Provider.getModel(userMessage.model.providerID, userMessage.model.modelID),
|
||||||
|
)
|
||||||
// Allow plugins to inject context or replace compaction prompt.
|
// Allow plugins to inject context or replace compaction prompt.
|
||||||
const compacting = yield* plugin.trigger(
|
const compacting = yield* plugin.trigger(
|
||||||
"experimental.session.compacting",
|
"experimental.session.compacting",
|
||||||
@@ -219,7 +213,6 @@ When constructing the summary, try to stick to this template:
|
|||||||
const msgs = structuredClone(messages)
|
const msgs = structuredClone(messages)
|
||||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||||
const modelMessages = yield* Effect.promise(() => MessageV2.toModelMessages(msgs, model, { stripMedia: true }))
|
const modelMessages = yield* Effect.promise(() => MessageV2.toModelMessages(msgs, model, { stripMedia: true }))
|
||||||
const ctx = yield* InstanceState.context
|
|
||||||
const msg: MessageV2.Assistant = {
|
const msg: MessageV2.Assistant = {
|
||||||
id: MessageID.ascending(),
|
id: MessageID.ascending(),
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
@@ -230,8 +223,8 @@ When constructing the summary, try to stick to this template:
|
|||||||
variant: userMessage.variant,
|
variant: userMessage.variant,
|
||||||
summary: true,
|
summary: true,
|
||||||
path: {
|
path: {
|
||||||
cwd: ctx.directory,
|
cwd: Instance.directory,
|
||||||
root: ctx.worktree,
|
root: Instance.worktree,
|
||||||
},
|
},
|
||||||
cost: 0,
|
cost: 0,
|
||||||
tokens: {
|
tokens: {
|
||||||
@@ -382,7 +375,6 @@ When constructing the summary, try to stick to this template:
|
|||||||
export const defaultLayer = Layer.unwrap(
|
export const defaultLayer = Layer.unwrap(
|
||||||
Effect.sync(() =>
|
Effect.sync(() =>
|
||||||
layer.pipe(
|
layer.pipe(
|
||||||
Layer.provide(Provider.defaultLayer),
|
|
||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
Layer.provide(SessionProcessor.defaultLayer),
|
Layer.provide(SessionProcessor.defaultLayer),
|
||||||
Layer.provide(Agent.defaultLayer),
|
Layer.provide(Agent.defaultLayer),
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { Log } from "../util/log"
|
|||||||
import { updateSchema } from "../util/update-schema"
|
import { updateSchema } from "../util/update-schema"
|
||||||
import { MessageV2 } from "./message-v2"
|
import { MessageV2 } from "./message-v2"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
|
||||||
import { SessionPrompt } from "./prompt"
|
import { SessionPrompt } from "./prompt"
|
||||||
import { fn } from "@/util/fn"
|
import { fn } from "@/util/fn"
|
||||||
import { Command } from "../command"
|
import { Command } from "../command"
|
||||||
@@ -258,9 +257,6 @@ export namespace Session {
|
|||||||
const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? 0)
|
const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? 0)
|
||||||
const cacheWriteInputTokens = safe(
|
const cacheWriteInputTokens = safe(
|
||||||
(input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
|
(input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
|
||||||
// google-vertex-anthropic returns metadata under "vertex" key
|
|
||||||
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
|
|
||||||
input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ??
|
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
|
input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
@@ -383,12 +379,11 @@ export namespace Session {
|
|||||||
directory: string
|
directory: string
|
||||||
permission?: Permission.Ruleset
|
permission?: Permission.Ruleset
|
||||||
}) {
|
}) {
|
||||||
const ctx = yield* InstanceState.context
|
|
||||||
const result: Info = {
|
const result: Info = {
|
||||||
id: SessionID.descending(input.id),
|
id: SessionID.descending(input.id),
|
||||||
slug: Slug.create(),
|
slug: Slug.create(),
|
||||||
version: Installation.VERSION,
|
version: Installation.VERSION,
|
||||||
projectID: ctx.project.id,
|
projectID: Instance.project.id,
|
||||||
directory: input.directory,
|
directory: input.directory,
|
||||||
workspaceID: input.workspaceID,
|
workspaceID: input.workspaceID,
|
||||||
parentID: input.parentID,
|
parentID: input.parentID,
|
||||||
@@ -446,12 +441,12 @@ export namespace Session {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
|
const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
|
||||||
const ctx = yield* InstanceState.context
|
const project = Instance.project
|
||||||
const rows = yield* db((d) =>
|
const rows = yield* db((d) =>
|
||||||
d
|
d
|
||||||
.select()
|
.select()
|
||||||
.from(SessionTable)
|
.from(SessionTable)
|
||||||
.where(and(eq(SessionTable.project_id, ctx.project.id), eq(SessionTable.parent_id, parentID)))
|
.where(and(eq(SessionTable.project_id, project.id), eq(SessionTable.parent_id, parentID)))
|
||||||
.all(),
|
.all(),
|
||||||
)
|
)
|
||||||
return rows.map(fromRow)
|
return rows.map(fromRow)
|
||||||
@@ -498,10 +493,9 @@ export namespace Session {
|
|||||||
permission?: Permission.Ruleset
|
permission?: Permission.Ruleset
|
||||||
workspaceID?: WorkspaceID
|
workspaceID?: WorkspaceID
|
||||||
}) {
|
}) {
|
||||||
const directory = yield* InstanceState.directory
|
|
||||||
return yield* createNext({
|
return yield* createNext({
|
||||||
parentID: input?.parentID,
|
parentID: input?.parentID,
|
||||||
directory,
|
directory: Instance.directory,
|
||||||
title: input?.title,
|
title: input?.title,
|
||||||
permission: input?.permission,
|
permission: input?.permission,
|
||||||
workspaceID: input?.workspaceID,
|
workspaceID: input?.workspaceID,
|
||||||
@@ -509,11 +503,10 @@ export namespace Session {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const fork = Effect.fn("Session.fork")(function* (input: { sessionID: SessionID; messageID?: MessageID }) {
|
const fork = Effect.fn("Session.fork")(function* (input: { sessionID: SessionID; messageID?: MessageID }) {
|
||||||
const directory = yield* InstanceState.directory
|
|
||||||
const original = yield* get(input.sessionID)
|
const original = yield* get(input.sessionID)
|
||||||
const title = getForkedTitle(original.title)
|
const title = getForkedTitle(original.title)
|
||||||
const session = yield* createNext({
|
const session = yield* createNext({
|
||||||
directory,
|
directory: Instance.directory,
|
||||||
workspaceID: original.workspaceID,
|
workspaceID: original.workspaceID,
|
||||||
title,
|
title,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { Cause, Effect, Layer, Record, ServiceMap } from "effect"
|
import { Effect, Layer, Record, ServiceMap } from "effect"
|
||||||
import * as Queue from "effect/Queue"
|
|
||||||
import * as Stream from "effect/Stream"
|
import * as Stream from "effect/Stream"
|
||||||
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
|
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
|
||||||
import { mergeDeep, pipe } from "remeda"
|
import { mergeDeep, pipe } from "remeda"
|
||||||
@@ -53,22 +52,19 @@ export namespace LLM {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
return Service.of({
|
return Service.of({
|
||||||
stream(input) {
|
stream(input) {
|
||||||
return Stream.scoped(
|
const stream: Stream.Stream<Event, unknown> = Stream.scoped(
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const ctrl = yield* Effect.acquireRelease(
|
const ctrl = yield* Effect.acquireRelease(
|
||||||
Effect.sync(() => new AbortController()),
|
Effect.sync(() => new AbortController()),
|
||||||
(ctrl) => Effect.sync(() => ctrl.abort()),
|
(ctrl) => Effect.sync(() => ctrl.abort()),
|
||||||
)
|
)
|
||||||
|
|
||||||
const result = yield* Effect.promise(() => LLM.stream({ ...input, abort: ctrl.signal }))
|
const result = yield* Effect.promise(() => LLM.stream({ ...input, abort: ctrl.signal }))
|
||||||
|
return Stream.fromAsyncIterable(result.fullStream, (err) => err)
|
||||||
return Stream.fromAsyncIterable(result.fullStream, (e) =>
|
|
||||||
e instanceof Error ? e : new Error(String(e)),
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
return stream
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export namespace SessionProcessor {
|
|||||||
export interface Handle {
|
export interface Handle {
|
||||||
readonly message: MessageV2.Assistant
|
readonly message: MessageV2.Assistant
|
||||||
readonly partFromToolCall: (toolCallID: string) => MessageV2.ToolPart | undefined
|
readonly partFromToolCall: (toolCallID: string) => MessageV2.ToolPart | undefined
|
||||||
|
readonly metadata: (
|
||||||
|
toolCallID: string,
|
||||||
|
input: { title?: string; metadata?: Record<string, any> },
|
||||||
|
) => Effect.Effect<void>
|
||||||
readonly abort: () => Effect.Effect<void>
|
readonly abort: () => Effect.Effect<void>
|
||||||
readonly process: (streamInput: LLM.StreamInput) => Effect.Effect<Result>
|
readonly process: (streamInput: LLM.StreamInput) => Effect.Effect<Result>
|
||||||
}
|
}
|
||||||
@@ -46,6 +50,7 @@ export namespace SessionProcessor {
|
|||||||
|
|
||||||
interface ProcessorContext extends Input {
|
interface ProcessorContext extends Input {
|
||||||
toolcalls: Record<string, MessageV2.ToolPart>
|
toolcalls: Record<string, MessageV2.ToolPart>
|
||||||
|
toolmeta: Record<string, { title?: string; metadata?: Record<string, any> }>
|
||||||
shouldBreak: boolean
|
shouldBreak: boolean
|
||||||
snapshot: string | undefined
|
snapshot: string | undefined
|
||||||
blocked: boolean
|
blocked: boolean
|
||||||
@@ -89,6 +94,7 @@ export namespace SessionProcessor {
|
|||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
toolcalls: {},
|
toolcalls: {},
|
||||||
|
toolmeta: {},
|
||||||
shouldBreak: false,
|
shouldBreak: false,
|
||||||
snapshot: undefined,
|
snapshot: undefined,
|
||||||
blocked: false,
|
blocked: false,
|
||||||
@@ -172,13 +178,21 @@ export namespace SessionProcessor {
|
|||||||
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
||||||
}
|
}
|
||||||
const match = ctx.toolcalls[value.toolCallId]
|
const match = ctx.toolcalls[value.toolCallId]
|
||||||
|
const meta = ctx.toolmeta[value.toolCallId]
|
||||||
if (!match) return
|
if (!match) return
|
||||||
ctx.toolcalls[value.toolCallId] = yield* session.updatePart({
|
ctx.toolcalls[value.toolCallId] = yield* session.updatePart({
|
||||||
...match,
|
...match,
|
||||||
tool: value.toolName,
|
tool: value.toolName,
|
||||||
state: { status: "running", input: value.input, time: { start: Date.now() } },
|
state: {
|
||||||
|
status: "running",
|
||||||
|
input: value.input,
|
||||||
|
title: meta?.title,
|
||||||
|
metadata: meta?.metadata,
|
||||||
|
time: { start: Date.now() },
|
||||||
|
},
|
||||||
metadata: value.providerMetadata,
|
metadata: value.providerMetadata,
|
||||||
} satisfies MessageV2.ToolPart)
|
} satisfies MessageV2.ToolPart)
|
||||||
|
delete ctx.toolmeta[value.toolCallId]
|
||||||
|
|
||||||
const parts = yield* Effect.promise(() => MessageV2.parts(ctx.assistantMessage.id))
|
const parts = yield* Effect.promise(() => MessageV2.parts(ctx.assistantMessage.id))
|
||||||
const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)
|
const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)
|
||||||
@@ -224,6 +238,7 @@ export namespace SessionProcessor {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
delete ctx.toolcalls[value.toolCallId]
|
delete ctx.toolcalls[value.toolCallId]
|
||||||
|
delete ctx.toolmeta[value.toolCallId]
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +258,7 @@ export namespace SessionProcessor {
|
|||||||
ctx.blocked = ctx.shouldBreak
|
ctx.blocked = ctx.shouldBreak
|
||||||
}
|
}
|
||||||
delete ctx.toolcalls[value.toolCallId]
|
delete ctx.toolcalls[value.toolCallId]
|
||||||
|
delete ctx.toolmeta[value.toolCallId]
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,10 +310,12 @@ export namespace SessionProcessor {
|
|||||||
}
|
}
|
||||||
ctx.snapshot = undefined
|
ctx.snapshot = undefined
|
||||||
}
|
}
|
||||||
SessionSummary.summarize({
|
yield* Effect.promise(() =>
|
||||||
sessionID: ctx.sessionID,
|
SessionSummary.summarize({
|
||||||
messageID: ctx.assistantMessage.parentID,
|
sessionID: ctx.sessionID,
|
||||||
})
|
messageID: ctx.assistantMessage.parentID,
|
||||||
|
}),
|
||||||
|
).pipe(Effect.ignoreCause({ log: true, message: "session summary failed" }), Effect.forkDetach)
|
||||||
if (
|
if (
|
||||||
!ctx.assistantMessage.summary &&
|
!ctx.assistantMessage.summary &&
|
||||||
isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model })
|
isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model })
|
||||||
@@ -492,6 +510,24 @@ export namespace SessionProcessor {
|
|||||||
partFromToolCall(toolCallID: string) {
|
partFromToolCall(toolCallID: string) {
|
||||||
return ctx.toolcalls[toolCallID]
|
return ctx.toolcalls[toolCallID]
|
||||||
},
|
},
|
||||||
|
metadata: Effect.fn("SessionProcessor.metadata")(function* (toolCallID, input) {
|
||||||
|
const match = ctx.toolcalls[toolCallID]
|
||||||
|
if (!match || match.state.status !== "running") {
|
||||||
|
ctx.toolmeta[toolCallID] = {
|
||||||
|
...ctx.toolmeta[toolCallID],
|
||||||
|
...input,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.toolcalls[toolCallID] = yield* session.updatePart({
|
||||||
|
...match,
|
||||||
|
state: {
|
||||||
|
...match.state,
|
||||||
|
title: input.title ?? match.state.title,
|
||||||
|
metadata: input.metadata ?? match.state.metadata,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
abort,
|
abort,
|
||||||
process,
|
process,
|
||||||
} satisfies Handle
|
} satisfies Handle
|
||||||
|
|||||||
@@ -28,9 +28,7 @@ import { ReadTool } from "../tool/read"
|
|||||||
import { FileTime } from "../file/time"
|
import { FileTime } from "../file/time"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { ulid } from "ulid"
|
import { ulid } from "ulid"
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
import { spawn } from "child_process"
|
||||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
|
||||||
import * as Stream from "effect/Stream"
|
|
||||||
import { Command } from "../command"
|
import { Command } from "../command"
|
||||||
import { pathToFileURL, fileURLToPath } from "url"
|
import { pathToFileURL, fileURLToPath } from "url"
|
||||||
import { ConfigMarkdown } from "../config/markdown"
|
import { ConfigMarkdown } from "../config/markdown"
|
||||||
@@ -86,7 +84,6 @@ export namespace SessionPrompt {
|
|||||||
const status = yield* SessionStatus.Service
|
const status = yield* SessionStatus.Service
|
||||||
const sessions = yield* Session.Service
|
const sessions = yield* Session.Service
|
||||||
const agents = yield* Agent.Service
|
const agents = yield* Agent.Service
|
||||||
const provider = yield* Provider.Service
|
|
||||||
const processor = yield* SessionProcessor.Service
|
const processor = yield* SessionProcessor.Service
|
||||||
const compaction = yield* SessionCompaction.Service
|
const compaction = yield* SessionCompaction.Service
|
||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
@@ -98,10 +95,9 @@ export namespace SessionPrompt {
|
|||||||
const filetime = yield* FileTime.Service
|
const filetime = yield* FileTime.Service
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
const truncate = yield* Truncate.Service
|
const truncate = yield* Truncate.Service
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
|
|
||||||
const state = yield* InstanceState.make(
|
const cache = yield* InstanceState.make(
|
||||||
Effect.fn("SessionPrompt.state")(function* () {
|
Effect.fn("SessionPrompt.state")(function* () {
|
||||||
const runners = new Map<string, Runner<MessageV2.WithParts>>()
|
const runners = new Map<string, Runner<MessageV2.WithParts>>()
|
||||||
yield* Effect.addFinalizer(
|
yield* Effect.addFinalizer(
|
||||||
@@ -135,14 +131,14 @@ export namespace SessionPrompt {
|
|||||||
const assertNotBusy: (sessionID: SessionID) => Effect.Effect<void, Session.BusyError> = Effect.fn(
|
const assertNotBusy: (sessionID: SessionID) => Effect.Effect<void, Session.BusyError> = Effect.fn(
|
||||||
"SessionPrompt.assertNotBusy",
|
"SessionPrompt.assertNotBusy",
|
||||||
)(function* (sessionID: SessionID) {
|
)(function* (sessionID: SessionID) {
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(cache)
|
||||||
const runner = s.runners.get(sessionID)
|
const runner = s.runners.get(sessionID)
|
||||||
if (runner?.busy) throw new Session.BusyError(sessionID)
|
if (runner?.busy) throw new Session.BusyError(sessionID)
|
||||||
})
|
})
|
||||||
|
|
||||||
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
|
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
|
||||||
log.info("cancel", { sessionID })
|
log.info("cancel", { sessionID })
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(cache)
|
||||||
const runner = s.runners.get(sessionID)
|
const runner = s.runners.get(sessionID)
|
||||||
if (!runner || !runner.busy) {
|
if (!runner || !runner.busy) {
|
||||||
yield* status.set(sessionID, { type: "idle" })
|
yield* status.set(sessionID, { type: "idle" })
|
||||||
@@ -152,7 +148,6 @@ export namespace SessionPrompt {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) {
|
const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) {
|
||||||
const ctx = yield* InstanceState.context
|
|
||||||
const parts: PromptInput["parts"] = [{ type: "text", text: template }]
|
const parts: PromptInput["parts"] = [{ type: "text", text: template }]
|
||||||
const files = ConfigMarkdown.files(template)
|
const files = ConfigMarkdown.files(template)
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
@@ -164,7 +159,7 @@ export namespace SessionPrompt {
|
|||||||
seen.add(name)
|
seen.add(name)
|
||||||
const filepath = name.startsWith("~/")
|
const filepath = name.startsWith("~/")
|
||||||
? path.join(os.homedir(), name.slice(2))
|
? path.join(os.homedir(), name.slice(2))
|
||||||
: path.resolve(ctx.worktree, name)
|
: path.resolve(Instance.worktree, name)
|
||||||
|
|
||||||
const info = yield* fsys.stat(filepath).pipe(Effect.option)
|
const info = yield* fsys.stat(filepath).pipe(Effect.option)
|
||||||
if (Option.isNone(info)) {
|
if (Option.isNone(info)) {
|
||||||
@@ -210,14 +205,14 @@ export namespace SessionPrompt {
|
|||||||
|
|
||||||
const ag = yield* agents.get("title")
|
const ag = yield* agents.get("title")
|
||||||
if (!ag) return
|
if (!ag) return
|
||||||
const mdl = ag.model
|
|
||||||
? yield* provider.getModel(ag.model.providerID, ag.model.modelID)
|
|
||||||
: ((yield* provider.getSmallModel(input.providerID)) ??
|
|
||||||
(yield* provider.getModel(input.providerID, input.modelID)))
|
|
||||||
const msgs = onlySubtasks
|
|
||||||
? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }]
|
|
||||||
: yield* Effect.promise(() => MessageV2.toModelMessages(context, mdl))
|
|
||||||
const text = yield* Effect.promise(async (signal) => {
|
const text = yield* Effect.promise(async (signal) => {
|
||||||
|
const mdl = ag.model
|
||||||
|
? await Provider.getModel(ag.model.providerID, ag.model.modelID)
|
||||||
|
: ((await Provider.getSmallModel(input.providerID)) ??
|
||||||
|
(await Provider.getModel(input.providerID, input.modelID)))
|
||||||
|
const msgs = onlySubtasks
|
||||||
|
? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }]
|
||||||
|
: await MessageV2.toModelMessages(context, mdl)
|
||||||
const result = await LLM.stream({
|
const result = await LLM.stream({
|
||||||
agent: ag,
|
agent: ag,
|
||||||
user: firstInfo,
|
user: firstInfo,
|
||||||
@@ -389,7 +384,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
model: Provider.Model
|
model: Provider.Model
|
||||||
session: Session.Info
|
session: Session.Info
|
||||||
tools?: Record<string, boolean>
|
tools?: Record<string, boolean>
|
||||||
processor: Pick<SessionProcessor.Handle, "message" | "partFromToolCall">
|
processor: Pick<SessionProcessor.Handle, "message" | "partFromToolCall" | "metadata">
|
||||||
bypassAgentCheck: boolean
|
bypassAgentCheck: boolean
|
||||||
messages: MessageV2.WithParts[]
|
messages: MessageV2.WithParts[]
|
||||||
}) {
|
}) {
|
||||||
@@ -404,23 +399,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck },
|
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck },
|
||||||
agent: input.agent.name,
|
agent: input.agent.name,
|
||||||
messages: input.messages,
|
messages: input.messages,
|
||||||
metadata: (val) =>
|
metadata: (val) => Effect.runPromise(input.processor.metadata(options.toolCallId, val)),
|
||||||
Effect.runPromise(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const match = input.processor.partFromToolCall(options.toolCallId)
|
|
||||||
if (!match || !["running", "pending"].includes(match.state.status)) return
|
|
||||||
yield* sessions.updatePart({
|
|
||||||
...match,
|
|
||||||
state: {
|
|
||||||
title: val.title,
|
|
||||||
metadata: val.metadata,
|
|
||||||
status: "running",
|
|
||||||
input: args,
|
|
||||||
time: { start: Date.now() },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
ask: (req) =>
|
ask: (req) =>
|
||||||
Effect.runPromise(
|
Effect.runPromise(
|
||||||
permission.ask({
|
permission.ask({
|
||||||
@@ -558,7 +537,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
msgs: MessageV2.WithParts[]
|
msgs: MessageV2.WithParts[]
|
||||||
}) {
|
}) {
|
||||||
const { task, model, lastUser, sessionID, session, msgs } = input
|
const { task, model, lastUser, sessionID, session, msgs } = input
|
||||||
const ctx = yield* InstanceState.context
|
|
||||||
const taskTool = yield* Effect.promise(() => TaskTool.init())
|
const taskTool = yield* Effect.promise(() => TaskTool.init())
|
||||||
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
|
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
|
||||||
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
|
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
|
||||||
@@ -569,7 +547,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
mode: task.agent,
|
mode: task.agent,
|
||||||
agent: task.agent,
|
agent: task.agent,
|
||||||
variant: lastUser.variant,
|
variant: lastUser.variant,
|
||||||
path: { cwd: ctx.directory, root: ctx.worktree },
|
path: { cwd: Instance.directory, root: Instance.worktree },
|
||||||
cost: 0,
|
cost: 0,
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
modelID: taskModel.id,
|
modelID: taskModel.id,
|
||||||
@@ -740,7 +718,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
})
|
})
|
||||||
|
|
||||||
const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput, signal: AbortSignal) {
|
const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput, signal: AbortSignal) {
|
||||||
const ctx = yield* InstanceState.context
|
|
||||||
const session = yield* sessions.get(input.sessionID)
|
const session = yield* sessions.get(input.sessionID)
|
||||||
if (session.revert) {
|
if (session.revert) {
|
||||||
yield* Effect.promise(() => SessionRevert.cleanup(session))
|
yield* Effect.promise(() => SessionRevert.cleanup(session))
|
||||||
@@ -780,7 +757,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
mode: input.agent,
|
mode: input.agent,
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
cost: 0,
|
cost: 0,
|
||||||
path: { cwd: ctx.directory, root: ctx.worktree },
|
path: { cwd: Instance.directory, root: Instance.worktree },
|
||||||
time: { created: Date.now() },
|
time: { created: Date.now() },
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
@@ -812,26 +789,22 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
fish: { args: ["-c", input.command] },
|
fish: { args: ["-c", input.command] },
|
||||||
zsh: {
|
zsh: {
|
||||||
args: [
|
args: [
|
||||||
"-l",
|
|
||||||
"-c",
|
"-c",
|
||||||
|
"-l",
|
||||||
`
|
`
|
||||||
__oc_cwd=$PWD
|
|
||||||
[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
|
[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
|
||||||
[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
|
[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
|
||||||
cd "$__oc_cwd"
|
|
||||||
eval ${JSON.stringify(input.command)}
|
eval ${JSON.stringify(input.command)}
|
||||||
`,
|
`,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
bash: {
|
bash: {
|
||||||
args: [
|
args: [
|
||||||
"-l",
|
|
||||||
"-c",
|
"-c",
|
||||||
|
"-l",
|
||||||
`
|
`
|
||||||
__oc_cwd=$PWD
|
|
||||||
shopt -s expand_aliases
|
shopt -s expand_aliases
|
||||||
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
|
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
|
||||||
cd "$__oc_cwd"
|
|
||||||
eval ${JSON.stringify(input.command)}
|
eval ${JSON.stringify(input.command)}
|
||||||
`,
|
`,
|
||||||
],
|
],
|
||||||
@@ -839,30 +812,61 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
cmd: { args: ["/c", input.command] },
|
cmd: { args: ["/c", input.command] },
|
||||||
powershell: { args: ["-NoProfile", "-Command", input.command] },
|
powershell: { args: ["-NoProfile", "-Command", input.command] },
|
||||||
pwsh: { args: ["-NoProfile", "-Command", input.command] },
|
pwsh: { args: ["-NoProfile", "-Command", input.command] },
|
||||||
"": { args: ["-c", input.command] },
|
"": { args: ["-c", `${input.command}`] },
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = (invocations[shellName] ?? invocations[""]).args
|
const args = (invocations[shellName] ?? invocations[""]).args
|
||||||
const cwd = ctx.directory
|
const cwd = Instance.directory
|
||||||
const shellEnv = yield* plugin.trigger(
|
const shellEnv = yield* plugin.trigger(
|
||||||
"shell.env",
|
"shell.env",
|
||||||
{ cwd, sessionID: input.sessionID, callID: part.callID },
|
{ cwd, sessionID: input.sessionID, callID: part.callID },
|
||||||
{ env: {} },
|
{ env: {} },
|
||||||
)
|
)
|
||||||
|
const proc = yield* Effect.sync(() =>
|
||||||
const cmd = ChildProcess.make(sh, args, {
|
spawn(sh, args, {
|
||||||
cwd,
|
cwd,
|
||||||
extendEnv: true,
|
detached: process.platform !== "win32",
|
||||||
env: { ...shellEnv.env, TERM: "dumb" },
|
windowsHide: process.platform === "win32",
|
||||||
stdin: "ignore",
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
forceKillAfter: "3 seconds",
|
env: {
|
||||||
})
|
...process.env,
|
||||||
|
...shellEnv.env,
|
||||||
|
TERM: "dumb",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
let output = ""
|
let output = ""
|
||||||
|
const write = () => {
|
||||||
|
if (part.state.status !== "running") return
|
||||||
|
part.state.metadata = { output, description: "" }
|
||||||
|
void Effect.runFork(sessions.updatePart(part))
|
||||||
|
}
|
||||||
|
|
||||||
|
proc.stdout?.on("data", (chunk) => {
|
||||||
|
output += chunk.toString()
|
||||||
|
write()
|
||||||
|
})
|
||||||
|
proc.stderr?.on("data", (chunk) => {
|
||||||
|
output += chunk.toString()
|
||||||
|
write()
|
||||||
|
})
|
||||||
|
|
||||||
let aborted = false
|
let aborted = false
|
||||||
|
let exited = false
|
||||||
|
let finished = false
|
||||||
|
const kill = Effect.promise(() => Shell.killTree(proc, { exited: () => exited }))
|
||||||
|
|
||||||
|
const abortHandler = () => {
|
||||||
|
if (aborted) return
|
||||||
|
aborted = true
|
||||||
|
void Effect.runFork(kill)
|
||||||
|
}
|
||||||
|
|
||||||
const finish = Effect.uninterruptible(
|
const finish = Effect.uninterruptible(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
if (finished) return
|
||||||
|
finished = true
|
||||||
if (aborted) {
|
if (aborted) {
|
||||||
output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n")
|
output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n")
|
||||||
}
|
}
|
||||||
@@ -884,26 +888,20 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const exit = yield* Effect.gen(function* () {
|
const exit = yield* Effect.promise(() => {
|
||||||
const handle = yield* spawner.spawn(cmd)
|
signal.addEventListener("abort", abortHandler, { once: true })
|
||||||
yield* Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
|
if (signal.aborted) abortHandler()
|
||||||
Effect.sync(() => {
|
return new Promise<void>((resolve) => {
|
||||||
output += chunk
|
const close = () => {
|
||||||
if (part.state.status === "running") {
|
exited = true
|
||||||
part.state.metadata = { output, description: "" }
|
proc.off("close", close)
|
||||||
void Effect.runFork(sessions.updatePart(part))
|
resolve()
|
||||||
}
|
}
|
||||||
}),
|
proc.once("close", close)
|
||||||
)
|
})
|
||||||
yield* handle.exitCode
|
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.scoped,
|
Effect.onInterrupt(() => Effect.sync(abortHandler)),
|
||||||
Effect.onInterrupt(() =>
|
Effect.ensuring(Effect.sync(() => signal.removeEventListener("abort", abortHandler))),
|
||||||
Effect.sync(() => {
|
|
||||||
aborted = true
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Effect.orDie,
|
|
||||||
Effect.ensuring(finish),
|
Effect.ensuring(finish),
|
||||||
Effect.exit,
|
Effect.exit,
|
||||||
)
|
)
|
||||||
@@ -915,35 +913,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
return { info: msg, parts: [part] }
|
return { info: msg, parts: [part] }
|
||||||
})
|
})
|
||||||
|
|
||||||
const getModel = Effect.fn("SessionPrompt.getModel")(function* (
|
const getModel = (providerID: ProviderID, modelID: ModelID, sessionID: SessionID) =>
|
||||||
providerID: ProviderID,
|
Effect.promise(() =>
|
||||||
modelID: ModelID,
|
Provider.getModel(providerID, modelID).catch((e) => {
|
||||||
sessionID: SessionID,
|
if (Provider.ModelNotFoundError.isInstance(e)) {
|
||||||
) {
|
const hint = e.data.suggestions?.length ? ` Did you mean: ${e.data.suggestions.join(", ")}?` : ""
|
||||||
const exit = yield* provider.getModel(providerID, modelID).pipe(Effect.exit)
|
Bus.publish(Session.Event.Error, {
|
||||||
if (Exit.isSuccess(exit)) return exit.value
|
sessionID,
|
||||||
const err = Cause.squash(exit.cause)
|
error: new NamedError.Unknown({
|
||||||
if (Provider.ModelNotFoundError.isInstance(err)) {
|
message: `Model not found: ${e.data.providerID}/${e.data.modelID}.${hint}`,
|
||||||
const hint = err.data.suggestions?.length ? ` Did you mean: ${err.data.suggestions.join(", ")}?` : ""
|
}).toObject(),
|
||||||
yield* bus.publish(Session.Event.Error, {
|
})
|
||||||
sessionID,
|
}
|
||||||
error: new NamedError.Unknown({
|
throw e
|
||||||
message: `Model not found: ${err.data.providerID}/${err.data.modelID}.${hint}`,
|
}),
|
||||||
}).toObject(),
|
)
|
||||||
})
|
|
||||||
}
|
|
||||||
return yield* Effect.failCause(exit.cause)
|
|
||||||
})
|
|
||||||
|
|
||||||
const lastModel = Effect.fnUntraced(function* (sessionID: SessionID) {
|
|
||||||
const model = yield* Effect.promise(async () => {
|
|
||||||
for await (const item of MessageV2.stream(sessionID)) {
|
|
||||||
if (item.info.role === "user" && item.info.model) return item.info.model
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (model) return model
|
|
||||||
return yield* provider.defaultModel()
|
|
||||||
})
|
|
||||||
|
|
||||||
const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) {
|
const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) {
|
||||||
const agentName = input.agent || (yield* agents.defaultAgent())
|
const agentName = input.agent || (yield* agents.defaultAgent())
|
||||||
@@ -957,12 +941,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
}
|
}
|
||||||
|
|
||||||
const model = input.model ?? ag.model ?? (yield* lastModel(input.sessionID))
|
const model = input.model ?? ag.model ?? (yield* lastModel(input.sessionID))
|
||||||
const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID
|
|
||||||
const full =
|
const full =
|
||||||
!input.variant && ag.variant && same
|
!input.variant && ag.variant
|
||||||
? yield* provider
|
? yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID).catch(() => undefined))
|
||||||
.getModel(model.providerID, model.modelID)
|
|
||||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
||||||
: undefined
|
: undefined
|
||||||
const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined)
|
const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined)
|
||||||
|
|
||||||
@@ -979,7 +960,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
variant,
|
variant,
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* Effect.addFinalizer(() => InstanceState.withALS(() => InstructionPrompt.clear(info.id)))
|
yield* Effect.addFinalizer(() => Effect.sync(() => InstructionPrompt.clear(info.id)))
|
||||||
|
|
||||||
type Draft<T> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never
|
type Draft<T> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never
|
||||||
const assign = (part: Draft<MessageV2.Part>): MessageV2.Part => ({
|
const assign = (part: Draft<MessageV2.Part>): MessageV2.Part => ({
|
||||||
@@ -1109,7 +1090,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
]
|
]
|
||||||
const read = yield* Effect.promise(() => ReadTool.init()).pipe(
|
const read = yield* Effect.promise(() => ReadTool.init()).pipe(
|
||||||
Effect.flatMap((t) =>
|
Effect.flatMap((t) =>
|
||||||
provider.getModel(info.model.providerID, info.model.modelID).pipe(
|
Effect.promise(() => Provider.getModel(info.model.providerID, info.model.modelID)).pipe(
|
||||||
Effect.flatMap((mdl) =>
|
Effect.flatMap((mdl) =>
|
||||||
Effect.promise(() =>
|
Effect.promise(() =>
|
||||||
t.execute(args, {
|
t.execute(args, {
|
||||||
@@ -1333,7 +1314,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
|
|
||||||
const runLoop: (sessionID: SessionID) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.run")(
|
const runLoop: (sessionID: SessionID) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.run")(
|
||||||
function* (sessionID: SessionID) {
|
function* (sessionID: SessionID) {
|
||||||
const ctx = yield* InstanceState.context
|
|
||||||
let structured: unknown | undefined
|
let structured: unknown | undefined
|
||||||
let step = 0
|
let step = 0
|
||||||
const session = yield* sessions.get(sessionID)
|
const session = yield* sessions.get(sessionID)
|
||||||
@@ -1425,7 +1405,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
mode: agent.name,
|
mode: agent.name,
|
||||||
agent: agent.name,
|
agent: agent.name,
|
||||||
variant: lastUser.variant,
|
variant: lastUser.variant,
|
||||||
path: { cwd: ctx.directory, root: ctx.worktree },
|
path: { cwd: Instance.directory, root: Instance.worktree },
|
||||||
cost: 0,
|
cost: 0,
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
modelID: model.id,
|
modelID: model.id,
|
||||||
@@ -1542,7 +1522,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
}),
|
}),
|
||||||
Effect.fnUntraced(function* (exit) {
|
Effect.fnUntraced(function* (exit) {
|
||||||
if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) yield* handle.abort()
|
if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) yield* handle.abort()
|
||||||
yield* InstanceState.withALS(() => InstructionPrompt.clear(handle.message.id))
|
InstructionPrompt.clear(handle.message.id)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (outcome === "break") break
|
if (outcome === "break") break
|
||||||
@@ -1557,14 +1537,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
const loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts> = Effect.fn(
|
const loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts> = Effect.fn(
|
||||||
"SessionPrompt.loop",
|
"SessionPrompt.loop",
|
||||||
)(function* (input: z.infer<typeof LoopInput>) {
|
)(function* (input: z.infer<typeof LoopInput>) {
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(cache)
|
||||||
const runner = getRunner(s.runners, input.sessionID)
|
const runner = getRunner(s.runners, input.sessionID)
|
||||||
return yield* runner.ensureRunning(runLoop(input.sessionID))
|
return yield* runner.ensureRunning(runLoop(input.sessionID))
|
||||||
})
|
})
|
||||||
|
|
||||||
const shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.shell")(
|
const shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.shell")(
|
||||||
function* (input: ShellInput) {
|
function* (input: ShellInput) {
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(cache)
|
||||||
const runner = getRunner(s.runners, input.sessionID)
|
const runner = getRunner(s.runners, input.sessionID)
|
||||||
return yield* runner.startShell((signal) => shellImpl(input, signal))
|
return yield* runner.startShell((signal) => shellImpl(input, signal))
|
||||||
},
|
},
|
||||||
@@ -1711,13 +1691,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
Layer.provide(FileTime.defaultLayer),
|
Layer.provide(FileTime.defaultLayer),
|
||||||
Layer.provide(ToolRegistry.defaultLayer),
|
Layer.provide(ToolRegistry.defaultLayer),
|
||||||
Layer.provide(Truncate.layer),
|
Layer.provide(Truncate.layer),
|
||||||
Layer.provide(Provider.defaultLayer),
|
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
Layer.provide(Plugin.defaultLayer),
|
Layer.provide(Plugin.defaultLayer),
|
||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
Layer.provide(Agent.defaultLayer),
|
Layer.provide(Agent.defaultLayer),
|
||||||
Layer.provide(Bus.layer),
|
Layer.provide(Bus.layer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1858,6 +1836,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
return runPromise((svc) => svc.command(CommandInput.parse(input)))
|
return runPromise((svc) => svc.command(CommandInput.parse(input)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lastModel = Effect.fnUntraced(function* (sessionID: SessionID) {
|
||||||
|
return yield* Effect.promise(async () => {
|
||||||
|
for await (const item of MessageV2.stream(sessionID)) {
|
||||||
|
if (item.info.role === "user" && item.info.model) return item.info.model
|
||||||
|
}
|
||||||
|
return Provider.defaultModel()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
/** @internal Exported for testing */
|
/** @internal Exported for testing */
|
||||||
export function createStructuredOutputTool(input: {
|
export function createStructuredOutputTool(input: {
|
||||||
schema: Record<string, any>
|
schema: Record<string, any>
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { Bus } from "../bus"
|
|
||||||
import { Snapshot } from "../snapshot"
|
|
||||||
import { Storage } from "@/storage/storage"
|
|
||||||
import { SyncEvent } from "../sync"
|
|
||||||
import { Log } from "../util/log"
|
|
||||||
import { Session } from "."
|
|
||||||
import { MessageV2 } from "./message-v2"
|
|
||||||
import { SessionID, MessageID, PartID } from "./schema"
|
import { SessionID, MessageID, PartID } from "./schema"
|
||||||
|
import { Snapshot } from "../snapshot"
|
||||||
|
import { MessageV2 } from "./message-v2"
|
||||||
|
import { Session } from "."
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
import { SyncEvent } from "../sync"
|
||||||
|
import { Storage } from "@/storage/storage"
|
||||||
|
import { Bus } from "../bus"
|
||||||
import { SessionPrompt } from "./prompt"
|
import { SessionPrompt } from "./prompt"
|
||||||
import { SessionSummary } from "./summary"
|
import { SessionSummary } from "./summary"
|
||||||
|
|
||||||
@@ -22,152 +20,116 @@ export namespace SessionRevert {
|
|||||||
})
|
})
|
||||||
export type RevertInput = z.infer<typeof RevertInput>
|
export type RevertInput = z.infer<typeof RevertInput>
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly revert: (input: RevertInput) => Effect.Effect<Session.Info>
|
|
||||||
readonly unrevert: (input: { sessionID: SessionID }) => Effect.Effect<Session.Info>
|
|
||||||
readonly cleanup: (session: Session.Info) => Effect.Effect<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionRevert") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const sessions = yield* Session.Service
|
|
||||||
const snap = yield* Snapshot.Service
|
|
||||||
const storage = yield* Storage.Service
|
|
||||||
const bus = yield* Bus.Service
|
|
||||||
|
|
||||||
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
|
|
||||||
yield* Effect.promise(() => SessionPrompt.assertNotBusy(input.sessionID))
|
|
||||||
const all = yield* sessions.messages({ sessionID: input.sessionID })
|
|
||||||
let lastUser: MessageV2.User | undefined
|
|
||||||
const session = yield* sessions.get(input.sessionID)
|
|
||||||
|
|
||||||
let rev: Session.Info["revert"]
|
|
||||||
const patches: Snapshot.Patch[] = []
|
|
||||||
for (const msg of all) {
|
|
||||||
if (msg.info.role === "user") lastUser = msg.info
|
|
||||||
const remaining = []
|
|
||||||
for (const part of msg.parts) {
|
|
||||||
if (rev) {
|
|
||||||
if (part.type === "patch") patches.push(part)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!rev) {
|
|
||||||
if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) {
|
|
||||||
const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined
|
|
||||||
rev = {
|
|
||||||
messageID: !partID && lastUser ? lastUser.id : msg.info.id,
|
|
||||||
partID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
remaining.push(part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!rev) return session
|
|
||||||
|
|
||||||
rev.snapshot = session.revert?.snapshot ?? (yield* snap.track())
|
|
||||||
yield* snap.revert(patches)
|
|
||||||
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot as string)
|
|
||||||
const range = all.filter((msg) => msg.info.id >= rev!.messageID)
|
|
||||||
const diffs = yield* Effect.promise(() => SessionSummary.computeDiff({ messages: range }))
|
|
||||||
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
|
|
||||||
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
|
|
||||||
yield* sessions.setRevert({
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
revert: rev,
|
|
||||||
summary: {
|
|
||||||
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
|
|
||||||
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
|
|
||||||
files: diffs.length,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return yield* sessions.get(input.sessionID)
|
|
||||||
})
|
|
||||||
|
|
||||||
const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
|
|
||||||
log.info("unreverting", input)
|
|
||||||
yield* Effect.promise(() => SessionPrompt.assertNotBusy(input.sessionID))
|
|
||||||
const session = yield* sessions.get(input.sessionID)
|
|
||||||
if (!session.revert) return session
|
|
||||||
if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!)
|
|
||||||
yield* sessions.clearRevert(input.sessionID)
|
|
||||||
return yield* sessions.get(input.sessionID)
|
|
||||||
})
|
|
||||||
|
|
||||||
const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) {
|
|
||||||
if (!session.revert) return
|
|
||||||
const sessionID = session.id
|
|
||||||
const msgs = yield* sessions.messages({ sessionID })
|
|
||||||
const messageID = session.revert.messageID
|
|
||||||
const remove = [] as MessageV2.WithParts[]
|
|
||||||
let target: MessageV2.WithParts | undefined
|
|
||||||
for (const msg of msgs) {
|
|
||||||
if (msg.info.id < messageID) continue
|
|
||||||
if (msg.info.id > messageID) {
|
|
||||||
remove.push(msg)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (session.revert.partID) {
|
|
||||||
target = msg
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
remove.push(msg)
|
|
||||||
}
|
|
||||||
for (const msg of remove) {
|
|
||||||
SyncEvent.run(MessageV2.Event.Removed, {
|
|
||||||
sessionID,
|
|
||||||
messageID: msg.info.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (session.revert.partID && target) {
|
|
||||||
const partID = session.revert.partID
|
|
||||||
const idx = target.parts.findIndex((part) => part.id === partID)
|
|
||||||
if (idx >= 0) {
|
|
||||||
const removeParts = target.parts.slice(idx)
|
|
||||||
target.parts = target.parts.slice(0, idx)
|
|
||||||
for (const part of removeParts) {
|
|
||||||
SyncEvent.run(MessageV2.Event.PartRemoved, {
|
|
||||||
sessionID,
|
|
||||||
messageID: target.info.id,
|
|
||||||
partID: part.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
yield* sessions.clearRevert(sessionID)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ revert, unrevert, cleanup })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = Layer.unwrap(
|
|
||||||
Effect.sync(() =>
|
|
||||||
layer.pipe(
|
|
||||||
Layer.provide(Session.defaultLayer),
|
|
||||||
Layer.provide(Snapshot.defaultLayer),
|
|
||||||
Layer.provide(Storage.defaultLayer),
|
|
||||||
Layer.provide(Bus.layer),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function revert(input: RevertInput) {
|
export async function revert(input: RevertInput) {
|
||||||
return runPromise((svc) => svc.revert(input))
|
await SessionPrompt.assertNotBusy(input.sessionID)
|
||||||
|
const all = await Session.messages({ sessionID: input.sessionID })
|
||||||
|
let lastUser: MessageV2.User | undefined
|
||||||
|
const session = await Session.get(input.sessionID)
|
||||||
|
|
||||||
|
let revert: Session.Info["revert"]
|
||||||
|
const patches: Snapshot.Patch[] = []
|
||||||
|
for (const msg of all) {
|
||||||
|
if (msg.info.role === "user") lastUser = msg.info
|
||||||
|
const remaining = []
|
||||||
|
for (const part of msg.parts) {
|
||||||
|
if (revert) {
|
||||||
|
if (part.type === "patch") {
|
||||||
|
patches.push(part)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!revert) {
|
||||||
|
if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) {
|
||||||
|
// if no useful parts left in message, same as reverting whole message
|
||||||
|
const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined
|
||||||
|
revert = {
|
||||||
|
messageID: !partID && lastUser ? lastUser.id : msg.info.id,
|
||||||
|
partID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remaining.push(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (revert) {
|
||||||
|
const session = await Session.get(input.sessionID)
|
||||||
|
revert.snapshot = session.revert?.snapshot ?? (await Snapshot.track())
|
||||||
|
await Snapshot.revert(patches)
|
||||||
|
if (revert.snapshot) revert.diff = await Snapshot.diff(revert.snapshot)
|
||||||
|
const rangeMessages = all.filter((msg) => msg.info.id >= revert!.messageID)
|
||||||
|
const diffs = await SessionSummary.computeDiff({ messages: rangeMessages })
|
||||||
|
await Storage.write(["session_diff", input.sessionID], diffs)
|
||||||
|
Bus.publish(Session.Event.Diff, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
diff: diffs,
|
||||||
|
})
|
||||||
|
return Session.setRevert({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
revert,
|
||||||
|
summary: {
|
||||||
|
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
|
||||||
|
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
|
||||||
|
files: diffs.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unrevert(input: { sessionID: SessionID }) {
|
export async function unrevert(input: { sessionID: SessionID }) {
|
||||||
return runPromise((svc) => svc.unrevert(input))
|
log.info("unreverting", input)
|
||||||
|
await SessionPrompt.assertNotBusy(input.sessionID)
|
||||||
|
const session = await Session.get(input.sessionID)
|
||||||
|
if (!session.revert) return session
|
||||||
|
if (session.revert.snapshot) await Snapshot.restore(session.revert.snapshot)
|
||||||
|
return Session.clearRevert(input.sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanup(session: Session.Info) {
|
export async function cleanup(session: Session.Info) {
|
||||||
return runPromise((svc) => svc.cleanup(session))
|
if (!session.revert) return
|
||||||
|
const sessionID = session.id
|
||||||
|
const msgs = await Session.messages({ sessionID })
|
||||||
|
const messageID = session.revert.messageID
|
||||||
|
const remove = [] as MessageV2.WithParts[]
|
||||||
|
let target: MessageV2.WithParts | undefined
|
||||||
|
for (const msg of msgs) {
|
||||||
|
if (msg.info.id < messageID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (msg.info.id > messageID) {
|
||||||
|
remove.push(msg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (session.revert.partID) {
|
||||||
|
target = msg
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remove.push(msg)
|
||||||
|
}
|
||||||
|
for (const msg of remove) {
|
||||||
|
SyncEvent.run(MessageV2.Event.Removed, {
|
||||||
|
sessionID: sessionID,
|
||||||
|
messageID: msg.info.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (session.revert.partID && target) {
|
||||||
|
const partID = session.revert.partID
|
||||||
|
const removeStart = target.parts.findIndex((part) => part.id === partID)
|
||||||
|
if (removeStart >= 0) {
|
||||||
|
const preserveParts = target.parts.slice(0, removeStart)
|
||||||
|
const removeParts = target.parts.slice(removeStart)
|
||||||
|
target.parts = preserveParts
|
||||||
|
for (const part of removeParts) {
|
||||||
|
SyncEvent.run(MessageV2.Event.PartRemoved, {
|
||||||
|
sessionID: sessionID,
|
||||||
|
messageID: target.info.id,
|
||||||
|
partID: part.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Session.clearRevert(sessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
|
import { fn } from "@/util/fn"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { Bus } from "@/bus"
|
|
||||||
import { Snapshot } from "@/snapshot"
|
|
||||||
import { Storage } from "@/storage/storage"
|
|
||||||
import { Session } from "."
|
import { Session } from "."
|
||||||
|
|
||||||
import { MessageV2 } from "./message-v2"
|
import { MessageV2 } from "./message-v2"
|
||||||
import { SessionID, MessageID } from "./schema"
|
import { SessionID, MessageID } from "./schema"
|
||||||
|
import { Snapshot } from "@/snapshot"
|
||||||
|
|
||||||
|
import { Storage } from "@/storage/storage"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { NotFoundError } from "@/storage/db"
|
||||||
|
|
||||||
export namespace SessionSummary {
|
export namespace SessionSummary {
|
||||||
function unquoteGitPath(input: string) {
|
function unquoteGitPath(input: string) {
|
||||||
@@ -65,113 +67,103 @@ export namespace SessionSummary {
|
|||||||
return Buffer.from(bytes).toString()
|
return Buffer.from(bytes).toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export const summarize = fn(
|
||||||
readonly summarize: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect<void>
|
z.object({
|
||||||
readonly diff: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Snapshot.FileDiff[]>
|
sessionID: SessionID.zod,
|
||||||
readonly computeDiff: (input: { messages: MessageV2.WithParts[] }) => Effect.Effect<Snapshot.FileDiff[]>
|
messageID: MessageID.zod,
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionSummary") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const sessions = yield* Session.Service
|
|
||||||
const snapshot = yield* Snapshot.Service
|
|
||||||
const storage = yield* Storage.Service
|
|
||||||
const bus = yield* Bus.Service
|
|
||||||
|
|
||||||
const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: {
|
|
||||||
messages: MessageV2.WithParts[]
|
|
||||||
}) {
|
|
||||||
let from: string | undefined
|
|
||||||
let to: string | undefined
|
|
||||||
for (const item of input.messages) {
|
|
||||||
if (!from) {
|
|
||||||
for (const part of item.parts) {
|
|
||||||
if (part.type === "step-start" && part.snapshot) {
|
|
||||||
from = part.snapshot
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const part of item.parts) {
|
|
||||||
if (part.type === "step-finish" && part.snapshot) to = part.snapshot
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (from && to) return yield* snapshot.diffFull(from, to)
|
|
||||||
return []
|
|
||||||
})
|
|
||||||
|
|
||||||
const summarize = Effect.fn("SessionSummary.summarize")(function* (input: {
|
|
||||||
sessionID: SessionID
|
|
||||||
messageID: MessageID
|
|
||||||
}) {
|
|
||||||
const all = yield* sessions.messages({ sessionID: input.sessionID })
|
|
||||||
if (!all.length) return
|
|
||||||
|
|
||||||
const diffs = yield* computeDiff({ messages: all })
|
|
||||||
yield* sessions.setSummary({
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
summary: {
|
|
||||||
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
|
|
||||||
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
|
|
||||||
files: diffs.length,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
|
|
||||||
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
|
|
||||||
|
|
||||||
const messages = all.filter(
|
|
||||||
(m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID),
|
|
||||||
)
|
|
||||||
const target = messages.find((m) => m.info.id === input.messageID)
|
|
||||||
if (!target || target.info.role !== "user") return
|
|
||||||
const msgDiffs = yield* computeDiff({ messages })
|
|
||||||
target.info.summary = { ...target.info.summary, diffs: msgDiffs }
|
|
||||||
yield* sessions.updateMessage(target.info)
|
|
||||||
})
|
|
||||||
|
|
||||||
const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) {
|
|
||||||
const diffs = yield* storage
|
|
||||||
.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID])
|
|
||||||
.pipe(Effect.catch(() => Effect.succeed([] as Snapshot.FileDiff[])))
|
|
||||||
const next = diffs.map((item) => {
|
|
||||||
const file = unquoteGitPath(item.file)
|
|
||||||
if (file === item.file) return item
|
|
||||||
return { ...item, file }
|
|
||||||
})
|
|
||||||
const changed = next.some((item, i) => item.file !== diffs[i]?.file)
|
|
||||||
if (changed) yield* storage.write(["session_diff", input.sessionID], next).pipe(Effect.ignore)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ summarize, diff, computeDiff })
|
|
||||||
}),
|
}),
|
||||||
|
async (input) => {
|
||||||
|
await Session.messages({ sessionID: input.sessionID })
|
||||||
|
.then((all) =>
|
||||||
|
Promise.all([
|
||||||
|
summarizeSession({ sessionID: input.sessionID, messages: all }),
|
||||||
|
summarizeMessage({ messageID: input.messageID, messages: all }),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.catch((err) => {
|
||||||
|
if (NotFoundError.isInstance(err)) return
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
async function summarizeSession(input: { sessionID: SessionID; messages: MessageV2.WithParts[] }) {
|
||||||
Layer.provide(Session.defaultLayer),
|
const diffs = await computeDiff({ messages: input.messages })
|
||||||
Layer.provide(Snapshot.defaultLayer),
|
await Session.setSummary({
|
||||||
Layer.provide(Storage.defaultLayer),
|
sessionID: input.sessionID,
|
||||||
Layer.provide(Bus.layer),
|
summary: {
|
||||||
)
|
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
|
||||||
|
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
files: diffs.length,
|
||||||
|
},
|
||||||
export const summarize = (input: { sessionID: SessionID; messageID: MessageID }) =>
|
})
|
||||||
void runPromise((svc) => svc.summarize(input)).catch(() => {})
|
await Storage.write(["session_diff", input.sessionID], diffs)
|
||||||
|
Bus.publish(Session.Event.Diff, {
|
||||||
export const DiffInput = z.object({
|
sessionID: input.sessionID,
|
||||||
sessionID: SessionID.zod,
|
diff: diffs,
|
||||||
messageID: MessageID.zod.optional(),
|
})
|
||||||
})
|
|
||||||
|
|
||||||
export async function diff(input: z.infer<typeof DiffInput>) {
|
|
||||||
return runPromise((svc) => svc.diff(input))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function summarizeMessage(input: { messageID: string; messages: MessageV2.WithParts[] }) {
|
||||||
|
const messages = input.messages.filter(
|
||||||
|
(m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID),
|
||||||
|
)
|
||||||
|
const msgWithParts = messages.find((m) => m.info.id === input.messageID)
|
||||||
|
if (!msgWithParts || msgWithParts.info.role !== "user") return
|
||||||
|
const userMsg = msgWithParts.info
|
||||||
|
const diffs = await computeDiff({ messages })
|
||||||
|
userMsg.summary = {
|
||||||
|
...userMsg.summary,
|
||||||
|
diffs,
|
||||||
|
}
|
||||||
|
await Session.updateMessage(userMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const diff = fn(
|
||||||
|
z.object({
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
messageID: MessageID.zod.optional(),
|
||||||
|
}),
|
||||||
|
async (input) => {
|
||||||
|
const diffs = await Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]).catch(() => [])
|
||||||
|
const next = diffs.map((item) => {
|
||||||
|
const file = unquoteGitPath(item.file)
|
||||||
|
if (file === item.file) return item
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
file,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const changed = next.some((item, i) => item.file !== diffs[i]?.file)
|
||||||
|
if (changed) Storage.write(["session_diff", input.sessionID], next).catch(() => {})
|
||||||
|
return next
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
export async function computeDiff(input: { messages: MessageV2.WithParts[] }) {
|
export async function computeDiff(input: { messages: MessageV2.WithParts[] }) {
|
||||||
return runPromise((svc) => svc.computeDiff(input))
|
let from: string | undefined
|
||||||
|
let to: string | undefined
|
||||||
|
|
||||||
|
// scan assistant messages to find earliest from and latest to
|
||||||
|
// snapshot
|
||||||
|
for (const item of input.messages) {
|
||||||
|
if (!from) {
|
||||||
|
for (const part of item.parts) {
|
||||||
|
if (part.type === "step-start" && part.snapshot) {
|
||||||
|
from = part.snapshot
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const part of item.parts) {
|
||||||
|
if (part.type === "step-finish" && part.snapshot) {
|
||||||
|
to = part.snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from && to) return Snapshot.diffFull(from, to)
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ import { NamedError } from "@opencode-ai/util/error"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { readFileSync, readdirSync, existsSync } from "fs"
|
import { readFileSync, readdirSync, existsSync } from "fs"
|
||||||
|
import { Installation } from "../installation"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { CHANNEL } from "../installation/meta"
|
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { init } from "#db"
|
import { init } from "#db"
|
||||||
|
|
||||||
@@ -29,9 +28,10 @@ const log = Log.create({ service: "db" })
|
|||||||
|
|
||||||
export namespace Database {
|
export namespace Database {
|
||||||
export function getChannelPath() {
|
export function getChannelPath() {
|
||||||
if (["latest", "beta"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
|
const channel = Installation.CHANNEL
|
||||||
|
if (["latest", "beta"].includes(channel) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
|
||||||
return path.join(Global.Path.data, "opencode.db")
|
return path.join(Global.Path.data, "opencode.db")
|
||||||
const safe = CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
|
const safe = channel.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||||
return path.join(Global.Path.data, `opencode-${safe}.db`)
|
return path.join(Global.Path.data, `opencode-${safe}.db`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,11 +142,10 @@ export namespace Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function effect(fn: () => any | Promise<any>) {
|
export function effect(fn: () => any | Promise<any>) {
|
||||||
const bound = InstanceState.bind(fn)
|
|
||||||
try {
|
try {
|
||||||
ctx.use().effects.push(bound)
|
ctx.use().effects.push(fn)
|
||||||
} catch {
|
} catch {
|
||||||
bound()
|
fn()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,8 +162,12 @@ export namespace Database {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Context.NotFound) {
|
if (err instanceof Context.NotFound) {
|
||||||
const effects: (() => void | Promise<void>)[] = []
|
const effects: (() => void | Promise<void>)[] = []
|
||||||
const txCallback = InstanceState.bind((tx: TxOrDb) => ctx.provide({ tx, effects }, () => callback(tx)))
|
const result = Client().transaction(
|
||||||
const result = Client().transaction(txCallback, { behavior: options?.behavior })
|
(tx: TxOrDb) => {
|
||||||
|
return ctx.provide({ tx, effects }, () => callback(tx))
|
||||||
|
},
|
||||||
|
{ behavior: options?.behavior },
|
||||||
|
)
|
||||||
for (const effect of effects) effect()
|
for (const effect of effects) effect()
|
||||||
return result as NotPromise<T>
|
return result as NotPromise<T>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export namespace ToolRegistry {
|
|||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
const cache = yield* InstanceState.make<State>(
|
||||||
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
||||||
const custom: Tool.Info[] = []
|
const custom: Tool.Info[] = []
|
||||||
|
|
||||||
@@ -139,18 +139,18 @@ export namespace ToolRegistry {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const register = Effect.fn("ToolRegistry.register")(function* (tool: Tool.Info) {
|
const register = Effect.fn("ToolRegistry.register")(function* (tool: Tool.Info) {
|
||||||
const s = yield* InstanceState.get(state)
|
const state = yield* InstanceState.get(cache)
|
||||||
const idx = s.custom.findIndex((t) => t.id === tool.id)
|
const idx = state.custom.findIndex((t) => t.id === tool.id)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
s.custom.splice(idx, 1, tool)
|
state.custom.splice(idx, 1, tool)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.custom.push(tool)
|
state.custom.push(tool)
|
||||||
})
|
})
|
||||||
|
|
||||||
const ids = Effect.fn("ToolRegistry.ids")(function* () {
|
const ids = Effect.fn("ToolRegistry.ids")(function* () {
|
||||||
const s = yield* InstanceState.get(state)
|
const state = yield* InstanceState.get(cache)
|
||||||
const tools = yield* all(s.custom)
|
const tools = yield* all(state.custom)
|
||||||
return tools.map((t) => t.id)
|
return tools.map((t) => t.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -158,8 +158,8 @@ export namespace ToolRegistry {
|
|||||||
model: { providerID: ProviderID; modelID: ModelID },
|
model: { providerID: ProviderID; modelID: ModelID },
|
||||||
agent?: Agent.Info,
|
agent?: Agent.Info,
|
||||||
) {
|
) {
|
||||||
const s = yield* InstanceState.get(state)
|
const state = yield* InstanceState.get(cache)
|
||||||
const allTools = yield* all(s.custom)
|
const allTools = yield* all(state.custom)
|
||||||
const filtered = allTools.filter((tool) => {
|
const filtered = allTools.filter((tool) => {
|
||||||
if (tool.id === "codesearch" || tool.id === "websearch") {
|
if (tool.id === "codesearch" || tool.id === "websearch") {
|
||||||
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { NodePath } from "@effect/platform-node"
|
|||||||
import { AppFileSystem } from "@/filesystem"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
|
||||||
|
|
||||||
export namespace Worktree {
|
export namespace Worktree {
|
||||||
const log = Log.create({ service: "worktree" })
|
const log = Log.create({ service: "worktree" })
|
||||||
@@ -200,7 +199,6 @@ export namespace Worktree {
|
|||||||
|
|
||||||
const MAX_NAME_ATTEMPTS = 26
|
const MAX_NAME_ATTEMPTS = 26
|
||||||
const candidate = Effect.fn("Worktree.candidate")(function* (root: string, base?: string) {
|
const candidate = Effect.fn("Worktree.candidate")(function* (root: string, base?: string) {
|
||||||
const ctx = yield* InstanceState.context
|
|
||||||
for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) {
|
for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) {
|
||||||
const name = base ? (attempt === 0 ? base : `${base}-${Slug.create()}`) : Slug.create()
|
const name = base ? (attempt === 0 ? base : `${base}-${Slug.create()}`) : Slug.create()
|
||||||
const branch = `opencode/${name}`
|
const branch = `opencode/${name}`
|
||||||
@@ -209,7 +207,7 @@ export namespace Worktree {
|
|||||||
if (yield* fs.exists(directory).pipe(Effect.orDie)) continue
|
if (yield* fs.exists(directory).pipe(Effect.orDie)) continue
|
||||||
|
|
||||||
const ref = `refs/heads/${branch}`
|
const ref = `refs/heads/${branch}`
|
||||||
const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree })
|
const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: Instance.worktree })
|
||||||
if (branchCheck.code === 0) continue
|
if (branchCheck.code === 0) continue
|
||||||
|
|
||||||
return Info.parse({ name, branch, directory })
|
return Info.parse({ name, branch, directory })
|
||||||
@@ -218,12 +216,11 @@ export namespace Worktree {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (name?: string) {
|
const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (name?: string) {
|
||||||
const ctx = yield* InstanceState.context
|
if (Instance.project.vcs !== "git") {
|
||||||
if (ctx.project.vcs !== "git") {
|
|
||||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = pathSvc.join(Global.Path.data, "worktree", ctx.project.id)
|
const root = pathSvc.join(Global.Path.data, "worktree", Instance.project.id)
|
||||||
yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
|
yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
|
||||||
|
|
||||||
const base = name ? slugify(name) : ""
|
const base = name ? slugify(name) : ""
|
||||||
@@ -231,20 +228,18 @@ export namespace Worktree {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const setup = Effect.fnUntraced(function* (info: Info) {
|
const setup = Effect.fnUntraced(function* (info: Info) {
|
||||||
const ctx = yield* InstanceState.context
|
|
||||||
const created = yield* git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
|
const created = yield* git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
|
||||||
cwd: ctx.worktree,
|
cwd: Instance.worktree,
|
||||||
})
|
})
|
||||||
if (created.code !== 0) {
|
if (created.code !== 0) {
|
||||||
throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
|
throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* project.addSandbox(ctx.project.id, info.directory).pipe(Effect.catch(() => Effect.void))
|
yield* project.addSandbox(Instance.project.id, info.directory).pipe(Effect.catch(() => Effect.void))
|
||||||
})
|
})
|
||||||
|
|
||||||
const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
|
const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
|
||||||
const ctx = yield* InstanceState.context
|
const projectID = Instance.project.id
|
||||||
const projectID = ctx.project.id
|
|
||||||
const extra = startCommand?.trim()
|
const extra = startCommand?.trim()
|
||||||
|
|
||||||
const populated = yield* git(["reset", "--hard"], { cwd: info.directory })
|
const populated = yield* git(["reset", "--hard"], { cwd: info.directory })
|
||||||
|
|||||||
@@ -16,21 +16,21 @@ const truncate = Layer.effectDiscard(
|
|||||||
|
|
||||||
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
|
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
|
||||||
|
|
||||||
it.live("list returns empty when no accounts exist", () =>
|
it.effect("list returns empty when no accounts exist", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const accounts = yield* AccountRepo.use((r) => r.list())
|
const accounts = yield* AccountRepo.use((r) => r.list())
|
||||||
expect(accounts).toEqual([])
|
expect(accounts).toEqual([])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("active returns none when no accounts exist", () =>
|
it.effect("active returns none when no accounts exist", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const active = yield* AccountRepo.use((r) => r.active())
|
const active = yield* AccountRepo.use((r) => r.active())
|
||||||
expect(Option.isNone(active)).toBe(true)
|
expect(Option.isNone(active)).toBe(true)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("persistAccount inserts and getRow retrieves", () =>
|
it.effect("persistAccount inserts and getRow retrieves", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
yield* AccountRepo.use((r) =>
|
yield* AccountRepo.use((r) =>
|
||||||
@@ -56,7 +56,7 @@ it.live("persistAccount inserts and getRow retrieves", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("persistAccount sets the active account and org", () =>
|
it.effect("persistAccount sets the active account and org", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id1 = AccountID.make("user-1")
|
const id1 = AccountID.make("user-1")
|
||||||
const id2 = AccountID.make("user-2")
|
const id2 = AccountID.make("user-2")
|
||||||
@@ -93,7 +93,7 @@ it.live("persistAccount sets the active account and org", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("list returns all accounts", () =>
|
it.effect("list returns all accounts", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id1 = AccountID.make("user-1")
|
const id1 = AccountID.make("user-1")
|
||||||
const id2 = AccountID.make("user-2")
|
const id2 = AccountID.make("user-2")
|
||||||
@@ -128,7 +128,7 @@ it.live("list returns all accounts", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("remove deletes an account", () =>
|
it.effect("remove deletes an account", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ it.live("remove deletes an account", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("use stores the selected org and marks the account active", () =>
|
it.effect("use stores the selected org and marks the account active", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id1 = AccountID.make("user-1")
|
const id1 = AccountID.make("user-1")
|
||||||
const id2 = AccountID.make("user-2")
|
const id2 = AccountID.make("user-2")
|
||||||
@@ -191,7 +191,7 @@ it.live("use stores the selected org and marks the account active", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("persistToken updates token fields", () =>
|
it.effect("persistToken updates token fields", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
|
|
||||||
@@ -225,7 +225,7 @@ it.live("persistToken updates token fields", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("persistToken with no expiry sets token_expiry to null", () =>
|
it.effect("persistToken with no expiry sets token_expiry to null", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
|
|
||||||
@@ -255,7 +255,7 @@ it.live("persistToken with no expiry sets token_expiry to null", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("persistAccount upserts on conflict", () =>
|
it.effect("persistAccount upserts on conflict", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
|
|
||||||
@@ -295,7 +295,7 @@ it.live("persistAccount upserts on conflict", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("remove clears active state when deleting the active account", () =>
|
it.effect("remove clears active state when deleting the active account", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
|
|
||||||
@@ -318,7 +318,7 @@ it.live("remove clears active state when deleting the active account", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("getRow returns none for nonexistent account", () =>
|
it.effect("getRow returns none for nonexistent account", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope")))
|
const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope")))
|
||||||
expect(Option.isNone(row)).toBe(true)
|
expect(Option.isNone(row)).toBe(true)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const deviceTokenClient = (body: unknown, status = 400) =>
|
|||||||
const poll = (body: unknown, status = 400) =>
|
const poll = (body: unknown, status = 400) =>
|
||||||
Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
|
Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
|
||||||
|
|
||||||
it.live("orgsByAccount groups orgs per account", () =>
|
it.effect("orgsByAccount groups orgs per account", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* AccountRepo.use((r) =>
|
yield* AccountRepo.use((r) =>
|
||||||
r.persistAccount({
|
r.persistAccount({
|
||||||
@@ -107,7 +107,7 @@ it.live("orgsByAccount groups orgs per account", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("token refresh persists the new token", () =>
|
it.effect("token refresh persists the new token", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
|
|
||||||
@@ -148,71 +148,7 @@ it.live("token refresh persists the new token", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("concurrent config and token requests coalesce token refresh", () =>
|
it.effect("config sends the selected org header", () =>
|
||||||
Effect.gen(function* () {
|
|
||||||
const id = AccountID.make("user-1")
|
|
||||||
|
|
||||||
yield* AccountRepo.use((r) =>
|
|
||||||
r.persistAccount({
|
|
||||||
id,
|
|
||||||
email: "user@example.com",
|
|
||||||
url: "https://one.example.com",
|
|
||||||
accessToken: AccessToken.make("at_old"),
|
|
||||||
refreshToken: RefreshToken.make("rt_old"),
|
|
||||||
expiry: Date.now() - 1_000,
|
|
||||||
orgID: Option.some(OrgID.make("org-9")),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
let refreshCalls = 0
|
|
||||||
const client = HttpClient.make((req) =>
|
|
||||||
Effect.promise(async () => {
|
|
||||||
if (req.url === "https://one.example.com/auth/device/token") {
|
|
||||||
refreshCalls += 1
|
|
||||||
|
|
||||||
if (refreshCalls === 1) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
||||||
return json(req, {
|
|
||||||
access_token: "at_new",
|
|
||||||
refresh_token: "rt_new",
|
|
||||||
expires_in: 60,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return json(
|
|
||||||
req,
|
|
||||||
{
|
|
||||||
error: "invalid_grant",
|
|
||||||
error_description: "refresh token already used",
|
|
||||||
},
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.url === "https://one.example.com/api/config") {
|
|
||||||
return json(req, { config: { theme: "light", seats: 5 } })
|
|
||||||
}
|
|
||||||
|
|
||||||
return json(req, {}, 404)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const [cfg, token] = yield* Account.Service.use((s) =>
|
|
||||||
Effect.all([s.config(id, OrgID.make("org-9")), s.token(id)], { concurrency: 2 }),
|
|
||||||
).pipe(Effect.provide(live(client)))
|
|
||||||
|
|
||||||
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
|
|
||||||
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
|
||||||
expect(refreshCalls).toBe(1)
|
|
||||||
|
|
||||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
|
||||||
const value = Option.getOrThrow(row)
|
|
||||||
expect(value.access_token).toBe(AccessToken.make("at_new"))
|
|
||||||
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("config sends the selected org header", () =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
|
|
||||||
@@ -252,7 +188,7 @@ it.live("config sends the selected org header", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("poll stores the account and first org on success", () =>
|
it.effect("poll stores the account and first org on success", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const client = HttpClient.make((req) =>
|
const client = HttpClient.make((req) =>
|
||||||
Effect.succeed(
|
Effect.succeed(
|
||||||
@@ -323,7 +259,7 @@ for (const [name, body, expectedTag] of [
|
|||||||
"PollExpired",
|
"PollExpired",
|
||||||
],
|
],
|
||||||
] as const) {
|
] as const) {
|
||||||
it.live(`poll returns ${name} for ${body.error}`, () =>
|
it.effect(`poll returns ${name} for ${body.error}`, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const result = yield* poll(body)
|
const result = yield* poll(body)
|
||||||
expect(result._tag).toBe(expectedTag)
|
expect(result._tag).toBe(expectedTag)
|
||||||
@@ -331,7 +267,7 @@ for (const [name, body, expectedTag] of [
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
it.live("poll returns poll error for other OAuth errors", () =>
|
it.effect("poll returns poll error for other OAuth errors", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const result = yield* poll({
|
const result = yield* poll({
|
||||||
error: "server_error",
|
error: "server_error",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const live = Layer.mergeAll(Bus.layer, node)
|
|||||||
const it = testEffect(live)
|
const it = testEffect(live)
|
||||||
|
|
||||||
describe("Bus (Effect-native)", () => {
|
describe("Bus (Effect-native)", () => {
|
||||||
it.live("publish + subscribe stream delivers events", () =>
|
it.effect("publish + subscribe stream delivers events", () =>
|
||||||
provideTmpdirInstance(() =>
|
provideTmpdirInstance(() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
@@ -46,7 +46,7 @@ describe("Bus (Effect-native)", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("subscribe filters by event type", () =>
|
it.effect("subscribe filters by event type", () =>
|
||||||
provideTmpdirInstance(() =>
|
provideTmpdirInstance(() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
@@ -70,7 +70,7 @@ describe("Bus (Effect-native)", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("subscribeAll receives all types", () =>
|
it.effect("subscribeAll receives all types", () =>
|
||||||
provideTmpdirInstance(() =>
|
provideTmpdirInstance(() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
@@ -95,7 +95,7 @@ describe("Bus (Effect-native)", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("multiple subscribers each receive the event", () =>
|
it.effect("multiple subscribers each receive the event", () =>
|
||||||
provideTmpdirInstance(() =>
|
provideTmpdirInstance(() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
@@ -129,7 +129,7 @@ describe("Bus (Effect-native)", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("subscribeAll stream sees InstanceDisposed on disposal", () =>
|
it.effect("subscribeAll stream sees InstanceDisposed on disposal", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const dir = yield* tmpdirScoped()
|
const dir = yield* tmpdirScoped()
|
||||||
const types: string[] = []
|
const types: string[] = []
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
|
||||||
import { expect, test } from "bun:test"
|
|
||||||
import { createSlot, createSolidSlotRegistry, testRender, useRenderer } from "@opentui/solid"
|
|
||||||
import { onMount } from "solid-js"
|
|
||||||
|
|
||||||
type Slots = {
|
|
||||||
prompt: {}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("replace slot mounts plugin content once", async () => {
|
|
||||||
let mounts = 0
|
|
||||||
|
|
||||||
const Probe = () => {
|
|
||||||
onMount(() => {
|
|
||||||
mounts += 1
|
|
||||||
})
|
|
||||||
|
|
||||||
return <box />
|
|
||||||
}
|
|
||||||
|
|
||||||
const App = () => {
|
|
||||||
const renderer = useRenderer()
|
|
||||||
const reg = createSolidSlotRegistry<Slots>(renderer, {})
|
|
||||||
const Slot = createSlot(reg)
|
|
||||||
|
|
||||||
reg.register({
|
|
||||||
id: "plugin",
|
|
||||||
slots: {
|
|
||||||
prompt() {
|
|
||||||
return <Probe />
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box>
|
|
||||||
<Slot name="prompt" mode="replace">
|
|
||||||
<box />
|
|
||||||
</Slot>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
await testRender(() => <App />)
|
|
||||||
|
|
||||||
expect(mounts).toBe(1)
|
|
||||||
})
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { afterEach, expect, test } from "bun:test"
|
import { afterEach, expect, test } from "bun:test"
|
||||||
import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer, ManagedRuntime, ServiceMap } from "effect"
|
import { Duration, Effect, Layer, ManagedRuntime, ServiceMap } from "effect"
|
||||||
import { InstanceState } from "../../src/effect/instance-state"
|
import { InstanceState } from "../../src/effect/instance-state"
|
||||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
@@ -383,100 +382,3 @@ test("InstanceState dedupes concurrent lookups", async () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("InstanceState survives deferred resume from the same instance context", async () => {
|
|
||||||
await using tmp = await tmpdir({ git: true })
|
|
||||||
|
|
||||||
interface Api {
|
|
||||||
readonly get: (gate: Deferred.Deferred<void>) => Effect.Effect<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
class Test extends ServiceMap.Service<Test, Api>()("@test/DeferredResume") {
|
|
||||||
static readonly layer = Layer.effect(
|
|
||||||
Test,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
|
|
||||||
|
|
||||||
return Test.of({
|
|
||||||
get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred<void>) {
|
|
||||||
yield* Deferred.await(gate)
|
|
||||||
return yield* InstanceState.get(state)
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const rt = ManagedRuntime.make(Test.layer)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const gate = await Effect.runPromise(Deferred.make<void>())
|
|
||||||
const fiber = await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: () => Promise.resolve(rt.runFork(Test.use((svc) => svc.get(gate)))),
|
|
||||||
})
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: () => Effect.runPromise(Deferred.succeed(gate, void 0)),
|
|
||||||
})
|
|
||||||
const exit = await Effect.runPromise(Fiber.await(fiber))
|
|
||||||
|
|
||||||
expect(Exit.isSuccess(exit)).toBe(true)
|
|
||||||
if (Exit.isSuccess(exit)) {
|
|
||||||
expect(exit.value).toBe(tmp.path)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await rt.dispose()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("InstanceState survives deferred resume outside ALS when InstanceRef is set", async () => {
|
|
||||||
await using tmp = await tmpdir({ git: true })
|
|
||||||
|
|
||||||
interface Api {
|
|
||||||
readonly get: (gate: Deferred.Deferred<void>) => Effect.Effect<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
class Test extends ServiceMap.Service<Test, Api>()("@test/DeferredResumeOutside") {
|
|
||||||
static readonly layer = Layer.effect(
|
|
||||||
Test,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
|
|
||||||
|
|
||||||
return Test.of({
|
|
||||||
get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred<void>) {
|
|
||||||
yield* Deferred.await(gate)
|
|
||||||
return yield* InstanceState.get(state)
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const rt = ManagedRuntime.make(Test.layer)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const gate = await Effect.runPromise(Deferred.make<void>())
|
|
||||||
// Provide InstanceRef so the fiber carries the context even when
|
|
||||||
// the deferred is resolved from outside Instance.provide ALS.
|
|
||||||
const fiber = await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: () =>
|
|
||||||
Promise.resolve(
|
|
||||||
rt.runFork(Test.use((svc) => svc.get(gate)).pipe(Effect.provideService(InstanceRef, Instance.current))),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Resume from outside any Instance.provide — ALS is NOT set here
|
|
||||||
await Effect.runPromise(Deferred.succeed(gate, void 0))
|
|
||||||
const exit = await Effect.runPromise(Fiber.await(fiber))
|
|
||||||
|
|
||||||
expect(Exit.isSuccess(exit)).toBe(true)
|
|
||||||
if (Exit.isSuccess(exit)) {
|
|
||||||
expect(exit.value).toBe(tmp.path)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await rt.dispose()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { it } from "../lib/effect"
|
|||||||
describe("Runner", () => {
|
describe("Runner", () => {
|
||||||
// --- ensureRunning semantics ---
|
// --- ensureRunning semantics ---
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"ensureRunning starts work and returns result",
|
"ensureRunning starts work and returns result",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -18,7 +18,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"ensureRunning propagates work failures",
|
"ensureRunning propagates work failures",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -29,7 +29,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"concurrent callers share the same run",
|
"concurrent callers share the same run",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -51,7 +51,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"concurrent callers all receive same error",
|
"concurrent callers all receive same error",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -71,7 +71,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"ensureRunning can be called again after previous run completes",
|
"ensureRunning can be called again after previous run completes",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -81,7 +81,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"second ensureRunning ignores new work if already running",
|
"second ensureRunning ignores new work if already running",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -110,7 +110,7 @@ describe("Runner", () => {
|
|||||||
|
|
||||||
// --- cancel semantics ---
|
// --- cancel semantics ---
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"cancel interrupts running work",
|
"cancel interrupts running work",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -128,7 +128,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"cancel on idle is a no-op",
|
"cancel on idle is a no-op",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -138,7 +138,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"cancel with onInterrupt resolves callers gracefully",
|
"cancel with onInterrupt resolves callers gracefully",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -154,7 +154,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"cancel with queued callers resolves all",
|
"cancel with queued callers resolves all",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -175,7 +175,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"work can be started after cancel",
|
"work can be started after cancel",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -245,7 +245,7 @@ describe("Runner", () => {
|
|||||||
|
|
||||||
// --- shell semantics ---
|
// --- shell semantics ---
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"shell runs exclusively",
|
"shell runs exclusively",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -256,7 +256,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"shell rejects when run is active",
|
"shell rejects when run is active",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -272,7 +272,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"shell rejects when another shell is running",
|
"shell rejects when another shell is running",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -292,7 +292,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"shell rejects via busy callback and cancel still stops the first shell",
|
"shell rejects via busy callback and cancel still stops the first shell",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -323,7 +323,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"cancel interrupts shell that ignores abort signal",
|
"cancel interrupts shell that ignores abort signal",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -349,7 +349,7 @@ describe("Runner", () => {
|
|||||||
|
|
||||||
// --- shell→run handoff ---
|
// --- shell→run handoff ---
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"ensureRunning queues behind shell then runs after",
|
"ensureRunning queues behind shell then runs after",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -376,7 +376,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"multiple ensureRunning callers share the queued run behind shell",
|
"multiple ensureRunning callers share the queued run behind shell",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -407,7 +407,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"cancel during shell_then_run cancels both",
|
"cancel during shell_then_run cancels both",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -441,7 +441,7 @@ describe("Runner", () => {
|
|||||||
|
|
||||||
// --- lifecycle callbacks ---
|
// --- lifecycle callbacks ---
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"onIdle fires when returning to idle from running",
|
"onIdle fires when returning to idle from running",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -454,7 +454,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"onIdle fires on cancel",
|
"onIdle fires on cancel",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -470,7 +470,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"onBusy fires when shell starts",
|
"onBusy fires when shell starts",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -485,7 +485,7 @@ describe("Runner", () => {
|
|||||||
|
|
||||||
// --- busy flag ---
|
// --- busy flag ---
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"busy is true during run",
|
"busy is true during run",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -502,7 +502,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live(
|
it.effect(
|
||||||
"busy is true during shell",
|
"busy is true during shell",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import { Effect, Layer } from "effect"
|
|
||||||
import { Provider } from "../../src/provider/provider"
|
|
||||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
|
||||||
|
|
||||||
export namespace ProviderTest {
|
|
||||||
export function model(override: Partial<Provider.Model> = {}): Provider.Model {
|
|
||||||
const id = override.id ?? ModelID.make("gpt-5.2")
|
|
||||||
const providerID = override.providerID ?? ProviderID.make("openai")
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
providerID,
|
|
||||||
name: "Test Model",
|
|
||||||
capabilities: {
|
|
||||||
toolcall: true,
|
|
||||||
attachment: false,
|
|
||||||
reasoning: false,
|
|
||||||
temperature: true,
|
|
||||||
interleaved: false,
|
|
||||||
input: { text: true, image: false, audio: false, video: false, pdf: false },
|
|
||||||
output: { text: true, image: false, audio: false, video: false, pdf: false },
|
|
||||||
},
|
|
||||||
api: { id, url: "https://example.com", npm: "@ai-sdk/openai" },
|
|
||||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
|
||||||
limit: { context: 200_000, output: 10_000 },
|
|
||||||
status: "active",
|
|
||||||
options: {},
|
|
||||||
headers: {},
|
|
||||||
release_date: "2025-01-01",
|
|
||||||
...override,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function info(override: Partial<Provider.Info> = {}, mdl = model()): Provider.Info {
|
|
||||||
const id = override.id ?? mdl.providerID
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: "Test Provider",
|
|
||||||
source: "config",
|
|
||||||
env: [],
|
|
||||||
options: {},
|
|
||||||
models: { [mdl.id]: mdl },
|
|
||||||
...override,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fake(override: Partial<Provider.Interface> & { model?: Provider.Model; info?: Provider.Info } = {}) {
|
|
||||||
const mdl = override.model ?? model()
|
|
||||||
const row = override.info ?? info({}, mdl)
|
|
||||||
return {
|
|
||||||
model: mdl,
|
|
||||||
info: row,
|
|
||||||
layer: Layer.succeed(
|
|
||||||
Provider.Service,
|
|
||||||
Provider.Service.of({
|
|
||||||
list: Effect.fn("TestProvider.list")(() => Effect.succeed({ [row.id]: row })),
|
|
||||||
getProvider: Effect.fn("TestProvider.getProvider")((providerID) => {
|
|
||||||
if (providerID === row.id) return Effect.succeed(row)
|
|
||||||
return Effect.die(new Error(`Unknown test provider: ${providerID}`))
|
|
||||||
}),
|
|
||||||
getModel: Effect.fn("TestProvider.getModel")((providerID, modelID) => {
|
|
||||||
if (providerID === row.id && modelID === mdl.id) return Effect.succeed(mdl)
|
|
||||||
return Effect.die(new Error(`Unknown test model: ${providerID}/${modelID}`))
|
|
||||||
}),
|
|
||||||
getLanguage: Effect.fn("TestProvider.getLanguage")(() =>
|
|
||||||
Effect.die(new Error("ProviderTest.getLanguage not configured")),
|
|
||||||
),
|
|
||||||
closest: Effect.fn("TestProvider.closest")((providerID) =>
|
|
||||||
Effect.succeed(providerID === row.id ? { providerID: row.id, modelID: mdl.id } : undefined),
|
|
||||||
),
|
|
||||||
getSmallModel: Effect.fn("TestProvider.getSmallModel")((providerID) =>
|
|
||||||
Effect.succeed(providerID === row.id ? mdl : undefined),
|
|
||||||
),
|
|
||||||
defaultModel: Effect.fn("TestProvider.defaultModel")(() =>
|
|
||||||
Effect.succeed({ providerID: row.id, modelID: mdl.id }),
|
|
||||||
),
|
|
||||||
...override,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,14 +2,10 @@ import { $ } from "bun"
|
|||||||
import * as fs from "fs/promises"
|
import * as fs from "fs/promises"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect, ServiceMap } from "effect"
|
import { Effect, FileSystem, ServiceMap } from "effect"
|
||||||
import type * as PlatformError from "effect/PlatformError"
|
|
||||||
import type * as Scope from "effect/Scope"
|
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
import type { Config } from "../../src/config/config"
|
import type { Config } from "../../src/config/config"
|
||||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { TestLLMServer } from "../lib/llm-server"
|
|
||||||
|
|
||||||
// Strip null bytes from paths (defensive fix for CI environment issues)
|
// Strip null bytes from paths (defensive fix for CI environment issues)
|
||||||
function sanitizePath(p: string): string {
|
function sanitizePath(p: string): string {
|
||||||
@@ -82,17 +78,9 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
|
|||||||
/** Effectful scoped tmpdir. Cleaned up when the scope closes. Make sure these stay in sync */
|
/** Effectful scoped tmpdir. Cleaned up when the scope closes. Make sure these stay in sync */
|
||||||
export function tmpdirScoped(options?: { git?: boolean; config?: Partial<Config.Info> }) {
|
export function tmpdirScoped(options?: { git?: boolean; config?: Partial<Config.Info> }) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
|
const fs = yield* FileSystem.FileSystem
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
const dirpath = sanitizePath(path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2)))
|
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" })
|
||||||
yield* Effect.promise(() => fs.mkdir(dirpath, { recursive: true }))
|
|
||||||
const dir = sanitizePath(yield* Effect.promise(() => fs.realpath(dirpath)))
|
|
||||||
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.promise(async () => {
|
|
||||||
if (options?.git) await stop(dir).catch(() => undefined)
|
|
||||||
await clean(dir).catch(() => undefined)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const git = (...args: string[]) =>
|
const git = (...args: string[]) =>
|
||||||
spawner.spawn(ChildProcess.make("git", args, { cwd: dir })).pipe(Effect.flatMap((handle) => handle.exitCode))
|
spawner.spawn(ChildProcess.make("git", args, { cwd: dir })).pipe(Effect.flatMap((handle) => handle.exitCode))
|
||||||
@@ -106,11 +94,9 @@ export function tmpdirScoped(options?: { git?: boolean; config?: Partial<Config.
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options?.config) {
|
if (options?.config) {
|
||||||
yield* Effect.promise(() =>
|
yield* fs.writeFileString(
|
||||||
fs.writeFile(
|
path.join(dir, "opencode.json"),
|
||||||
path.join(dir, "opencode.json"),
|
JSON.stringify({ $schema: "https://opencode.ai/config.json", ...options.config }),
|
||||||
JSON.stringify({ $schema: "https://opencode.ai/config.json", ...options.config }),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +111,7 @@ export const provideInstance =
|
|||||||
Effect.promise<A>(async () =>
|
Effect.promise<A>(async () =>
|
||||||
Instance.provide({
|
Instance.provide({
|
||||||
directory,
|
directory,
|
||||||
fn: () => Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, Instance.current))),
|
fn: () => Effect.runPromiseWith(services)(self),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -153,20 +139,3 @@ export function provideTmpdirInstance<A, E, R>(
|
|||||||
return yield* self(path).pipe(provideInstance(path))
|
return yield* self(path).pipe(provideInstance(path))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function provideTmpdirServer<A, E, R>(
|
|
||||||
self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect<A, E, R>,
|
|
||||||
options?: { git?: boolean; config?: (url: string) => Partial<Config.Info> },
|
|
||||||
): Effect.Effect<
|
|
||||||
A,
|
|
||||||
E | PlatformError.PlatformError,
|
|
||||||
R | TestLLMServer | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
|
|
||||||
> {
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
const llm = yield* TestLLMServer
|
|
||||||
return yield* provideTmpdirInstance((dir) => self({ dir, llm }), {
|
|
||||||
git: options?.git,
|
|
||||||
config: options?.config?.(llm.url),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import * as Formatter from "../../src/format/formatter"
|
|||||||
const it = testEffect(Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
|
const it = testEffect(Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
|
||||||
|
|
||||||
describe("Format", () => {
|
describe("Format", () => {
|
||||||
it.live("status() returns built-in formatters when no config overrides", () =>
|
it.effect("status() returns built-in formatters when no config overrides", () =>
|
||||||
provideTmpdirInstance(() =>
|
provideTmpdirInstance(() =>
|
||||||
Format.Service.use((fmt) =>
|
Format.Service.use((fmt) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -32,7 +32,7 @@ describe("Format", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("status() returns empty list when formatter is disabled", () =>
|
it.effect("status() returns empty list when formatter is disabled", () =>
|
||||||
provideTmpdirInstance(
|
provideTmpdirInstance(
|
||||||
() =>
|
() =>
|
||||||
Format.Service.use((fmt) =>
|
Format.Service.use((fmt) =>
|
||||||
@@ -44,7 +44,7 @@ describe("Format", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("status() excludes formatters marked as disabled in config", () =>
|
it.effect("status() excludes formatters marked as disabled in config", () =>
|
||||||
provideTmpdirInstance(
|
provideTmpdirInstance(
|
||||||
() =>
|
() =>
|
||||||
Format.Service.use((fmt) =>
|
Format.Service.use((fmt) =>
|
||||||
@@ -64,9 +64,11 @@ describe("Format", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("service initializes without error", () => provideTmpdirInstance(() => Format.Service.use(() => Effect.void)))
|
it.effect("service initializes without error", () =>
|
||||||
|
provideTmpdirInstance(() => Format.Service.use(() => Effect.void)),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("status() initializes formatter state per directory", () =>
|
it.effect("status() initializes formatter state per directory", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const a = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()), {
|
const a = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()), {
|
||||||
config: { formatter: false },
|
config: { formatter: false },
|
||||||
@@ -78,7 +80,7 @@ describe("Format", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("runs enabled checks for matching formatters in parallel", () =>
|
it.effect("runs enabled checks for matching formatters in parallel", () =>
|
||||||
provideTmpdirInstance((path) =>
|
provideTmpdirInstance((path) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const file = `${path}/test.parallel`
|
const file = `${path}/test.parallel`
|
||||||
@@ -142,7 +144,7 @@ describe("Format", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("runs matching formatters sequentially for the same file", () =>
|
it.effect("runs matching formatters sequentially for the same file", () =>
|
||||||
provideTmpdirInstance(
|
provideTmpdirInstance(
|
||||||
(path) =>
|
(path) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { test, type TestOptions } from "bun:test"
|
import { test, type TestOptions } from "bun:test"
|
||||||
import { Cause, Effect, Exit, Layer } from "effect"
|
import { Cause, Effect, Exit, Layer } from "effect"
|
||||||
import type * as Scope from "effect/Scope"
|
import type * as Scope from "effect/Scope"
|
||||||
import * as TestClock from "effect/testing/TestClock"
|
|
||||||
import * as TestConsole from "effect/testing/TestConsole"
|
import * as TestConsole from "effect/testing/TestConsole"
|
||||||
|
|
||||||
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
|
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
|
||||||
|
const env = TestConsole.layer
|
||||||
|
|
||||||
const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
|
const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
|
||||||
|
|
||||||
const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2>) =>
|
const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2, never>) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
|
const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
|
||||||
if (Exit.isFailure(exit)) {
|
if (Exit.isFailure(exit)) {
|
||||||
@@ -19,35 +19,19 @@ const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer
|
|||||||
return yield* exit
|
return yield* exit
|
||||||
}).pipe(Effect.runPromise)
|
}).pipe(Effect.runPromise)
|
||||||
|
|
||||||
const make = <R, E>(testLayer: Layer.Layer<R, E>, liveLayer: Layer.Layer<R, E>) => {
|
const make = <R, E>(layer: Layer.Layer<R, E, never>) => {
|
||||||
const effect = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
const effect = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
||||||
test(name, () => run(value, testLayer), opts)
|
test(name, () => run(value, layer), opts)
|
||||||
|
|
||||||
effect.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
effect.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
||||||
test.only(name, () => run(value, testLayer), opts)
|
test.only(name, () => run(value, layer), opts)
|
||||||
|
|
||||||
effect.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
effect.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
||||||
test.skip(name, () => run(value, testLayer), opts)
|
test.skip(name, () => run(value, layer), opts)
|
||||||
|
|
||||||
const live = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
return { effect }
|
||||||
test(name, () => run(value, liveLayer), opts)
|
|
||||||
|
|
||||||
live.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
|
||||||
test.only(name, () => run(value, liveLayer), opts)
|
|
||||||
|
|
||||||
live.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
|
||||||
test.skip(name, () => run(value, liveLayer), opts)
|
|
||||||
|
|
||||||
return { effect, live }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test environment with TestClock and TestConsole
|
export const it = make(env)
|
||||||
const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer())
|
|
||||||
|
|
||||||
// Live environment - uses real clock, but keeps TestConsole for output capture
|
export const testEffect = <R, E>(layer: Layer.Layer<R, E, never>) => make(Layer.provideMerge(layer, env))
|
||||||
const liveEnv = TestConsole.layer
|
|
||||||
|
|
||||||
export const it = make(testEnv, liveEnv)
|
|
||||||
|
|
||||||
export const testEffect = <R, E>(layer: Layer.Layer<R, E>) =>
|
|
||||||
make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv))
|
|
||||||
|
|||||||
@@ -1,731 +0,0 @@
|
|||||||
import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
|
|
||||||
import * as Http from "node:http"
|
|
||||||
import { Deferred, Effect, Layer, ServiceMap, Stream } from "effect"
|
|
||||||
import * as HttpServer from "effect/unstable/http/HttpServer"
|
|
||||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
|
||||||
|
|
||||||
export type Usage = { input: number; output: number }
|
|
||||||
|
|
||||||
type Line = Record<string, unknown>
|
|
||||||
|
|
||||||
type Flow =
|
|
||||||
| { type: "text"; text: string }
|
|
||||||
| { type: "reason"; text: string }
|
|
||||||
| { type: "tool-start"; id: string; name: string }
|
|
||||||
| { type: "tool-args"; text: string }
|
|
||||||
| { type: "usage"; usage: Usage }
|
|
||||||
|
|
||||||
type Hit = {
|
|
||||||
url: URL
|
|
||||||
body: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
type Match = (hit: Hit) => boolean
|
|
||||||
|
|
||||||
type Queue = {
|
|
||||||
item: Item
|
|
||||||
match?: Match
|
|
||||||
}
|
|
||||||
|
|
||||||
type Wait = {
|
|
||||||
count: number
|
|
||||||
ready: Deferred.Deferred<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
type Sse = {
|
|
||||||
type: "sse"
|
|
||||||
head: unknown[]
|
|
||||||
tail: unknown[]
|
|
||||||
wait?: PromiseLike<unknown>
|
|
||||||
hang?: boolean
|
|
||||||
error?: unknown
|
|
||||||
reset?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type HttpError = {
|
|
||||||
type: "http-error"
|
|
||||||
status: number
|
|
||||||
body: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Item = Sse | HttpError
|
|
||||||
|
|
||||||
const done = Symbol("done")
|
|
||||||
|
|
||||||
function line(input: unknown) {
|
|
||||||
if (input === done) return "data: [DONE]\n\n"
|
|
||||||
return `data: ${JSON.stringify(input)}\n\n`
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokens(input?: Usage) {
|
|
||||||
if (!input) return
|
|
||||||
return {
|
|
||||||
prompt_tokens: input.input,
|
|
||||||
completion_tokens: input.output,
|
|
||||||
total_tokens: input.input + input.output,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function chunk(input: { delta?: Record<string, unknown>; finish?: string; usage?: Usage }) {
|
|
||||||
return {
|
|
||||||
id: "chatcmpl-test",
|
|
||||||
object: "chat.completion.chunk",
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
delta: input.delta ?? {},
|
|
||||||
...(input.finish ? { finish_reason: input.finish } : {}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
...(input.usage ? { usage: tokens(input.usage) } : {}),
|
|
||||||
} satisfies Line
|
|
||||||
}
|
|
||||||
|
|
||||||
function role() {
|
|
||||||
return chunk({ delta: { role: "assistant" } })
|
|
||||||
}
|
|
||||||
|
|
||||||
function textLine(value: string) {
|
|
||||||
return chunk({ delta: { content: value } })
|
|
||||||
}
|
|
||||||
|
|
||||||
function reasonLine(value: string) {
|
|
||||||
return chunk({ delta: { reasoning_content: value } })
|
|
||||||
}
|
|
||||||
|
|
||||||
function finishLine(reason: string, usage?: Usage) {
|
|
||||||
return chunk({ finish: reason, usage })
|
|
||||||
}
|
|
||||||
|
|
||||||
function toolStartLine(id: string, name: string) {
|
|
||||||
return chunk({
|
|
||||||
delta: {
|
|
||||||
tool_calls: [
|
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
id,
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name,
|
|
||||||
arguments: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function toolArgsLine(value: string) {
|
|
||||||
return chunk({
|
|
||||||
delta: {
|
|
||||||
tool_calls: [
|
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
function: {
|
|
||||||
arguments: value,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function bytes(input: Iterable<unknown>) {
|
|
||||||
return Stream.fromIterable([...input].map(line)).pipe(Stream.encodeText)
|
|
||||||
}
|
|
||||||
|
|
||||||
function responseCreated(model: string) {
|
|
||||||
return {
|
|
||||||
type: "response.created",
|
|
||||||
sequence_number: 1,
|
|
||||||
response: {
|
|
||||||
id: "resp_test",
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
model,
|
|
||||||
service_tier: null,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function responseCompleted(input: { seq: number; usage?: Usage }) {
|
|
||||||
return {
|
|
||||||
type: "response.completed",
|
|
||||||
sequence_number: input.seq,
|
|
||||||
response: {
|
|
||||||
incomplete_details: null,
|
|
||||||
service_tier: null,
|
|
||||||
usage: {
|
|
||||||
input_tokens: input.usage?.input ?? 0,
|
|
||||||
input_tokens_details: { cached_tokens: null },
|
|
||||||
output_tokens: input.usage?.output ?? 0,
|
|
||||||
output_tokens_details: { reasoning_tokens: null },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function responseMessage(id: string, seq: number) {
|
|
||||||
return {
|
|
||||||
type: "response.output_item.added",
|
|
||||||
sequence_number: seq,
|
|
||||||
output_index: 0,
|
|
||||||
item: { type: "message", id },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function responseText(id: string, text: string, seq: number) {
|
|
||||||
return {
|
|
||||||
type: "response.output_text.delta",
|
|
||||||
sequence_number: seq,
|
|
||||||
item_id: id,
|
|
||||||
delta: text,
|
|
||||||
logprobs: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function responseMessageDone(id: string, seq: number) {
|
|
||||||
return {
|
|
||||||
type: "response.output_item.done",
|
|
||||||
sequence_number: seq,
|
|
||||||
output_index: 0,
|
|
||||||
item: { type: "message", id },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function responseReason(id: string, seq: number) {
|
|
||||||
return {
|
|
||||||
type: "response.output_item.added",
|
|
||||||
sequence_number: seq,
|
|
||||||
output_index: 0,
|
|
||||||
item: { type: "reasoning", id, encrypted_content: null },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function responseReasonPart(id: string, seq: number) {
|
|
||||||
return {
|
|
||||||
type: "response.reasoning_summary_part.added",
|
|
||||||
sequence_number: seq,
|
|
||||||
item_id: id,
|
|
||||||
summary_index: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function responseReasonText(id: string, text: string, seq: number) {
|
|
||||||
return {
|
|
||||||
type: "response.reasoning_summary_text.delta",
|
|
||||||
sequence_number: seq,
|
|
||||||
item_id: id,
|
|
||||||
summary_index: 0,
|
|
||||||
delta: text,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function responseReasonDone(id: string, seq: number) {
|
|
||||||
return {
|
|
||||||
type: "response.output_item.done",
|
|
||||||
sequence_number: seq,
|
|
||||||
output_index: 0,
|
|
||||||
item: { type: "reasoning", id, encrypted_content: null },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function responseTool(id: string, item: string, name: string, seq: number) {
|
|
||||||
return {
|
|
||||||
type: "response.output_item.added",
|
|
||||||
sequence_number: seq,
|
|
||||||
output_index: 0,
|
|
||||||
item: {
|
|
||||||
type: "function_call",
|
|
||||||
id: item,
|
|
||||||
call_id: id,
|
|
||||||
name,
|
|
||||||
arguments: "",
|
|
||||||
status: "in_progress",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function responseToolArgs(id: string, text: string, seq: number) {
|
|
||||||
return {
|
|
||||||
type: "response.function_call_arguments.delta",
|
|
||||||
sequence_number: seq,
|
|
||||||
output_index: 0,
|
|
||||||
item_id: id,
|
|
||||||
delta: text,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function responseToolDone(tool: { id: string; item: string; name: string; args: string }, seq: number) {
|
|
||||||
return {
|
|
||||||
type: "response.output_item.done",
|
|
||||||
sequence_number: seq,
|
|
||||||
output_index: 0,
|
|
||||||
item: {
|
|
||||||
type: "function_call",
|
|
||||||
id: tool.item,
|
|
||||||
call_id: tool.id,
|
|
||||||
name: tool.name,
|
|
||||||
arguments: tool.args,
|
|
||||||
status: "completed",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function choices(part: unknown) {
|
|
||||||
if (!part || typeof part !== "object") return
|
|
||||||
if (!("choices" in part) || !Array.isArray(part.choices)) return
|
|
||||||
const choice = part.choices[0]
|
|
||||||
if (!choice || typeof choice !== "object") return
|
|
||||||
return choice
|
|
||||||
}
|
|
||||||
|
|
||||||
function flow(item: Sse) {
|
|
||||||
const out: Flow[] = []
|
|
||||||
for (const part of [...item.head, ...item.tail]) {
|
|
||||||
const choice = choices(part)
|
|
||||||
const delta =
|
|
||||||
choice && "delta" in choice && choice.delta && typeof choice.delta === "object" ? choice.delta : undefined
|
|
||||||
|
|
||||||
if (delta && "content" in delta && typeof delta.content === "string") {
|
|
||||||
out.push({ type: "text", text: delta.content })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (delta && "reasoning_content" in delta && typeof delta.reasoning_content === "string") {
|
|
||||||
out.push({ type: "reason", text: delta.reasoning_content })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (delta && "tool_calls" in delta && Array.isArray(delta.tool_calls)) {
|
|
||||||
for (const tool of delta.tool_calls) {
|
|
||||||
if (!tool || typeof tool !== "object") continue
|
|
||||||
const fn = "function" in tool && tool.function && typeof tool.function === "object" ? tool.function : undefined
|
|
||||||
if ("id" in tool && typeof tool.id === "string" && fn && "name" in fn && typeof fn.name === "string") {
|
|
||||||
out.push({ type: "tool-start", id: tool.id, name: fn.name })
|
|
||||||
}
|
|
||||||
if (fn && "arguments" in fn && typeof fn.arguments === "string" && fn.arguments) {
|
|
||||||
out.push({ type: "tool-args", text: fn.arguments })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part && typeof part === "object" && "usage" in part && part.usage && typeof part.usage === "object") {
|
|
||||||
const raw = part.usage as Record<string, unknown>
|
|
||||||
if (typeof raw.prompt_tokens === "number" && typeof raw.completion_tokens === "number") {
|
|
||||||
out.push({
|
|
||||||
type: "usage",
|
|
||||||
usage: { input: raw.prompt_tokens, output: raw.completion_tokens },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function responses(item: Sse, model: string) {
|
|
||||||
let seq = 1
|
|
||||||
let msg: string | undefined
|
|
||||||
let reason: string | undefined
|
|
||||||
let hasMsg = false
|
|
||||||
let hasReason = false
|
|
||||||
let call:
|
|
||||||
| {
|
|
||||||
id: string
|
|
||||||
item: string
|
|
||||||
name: string
|
|
||||||
args: string
|
|
||||||
}
|
|
||||||
| undefined
|
|
||||||
let usage: Usage | undefined
|
|
||||||
const lines: unknown[] = [responseCreated(model)]
|
|
||||||
|
|
||||||
for (const part of flow(item)) {
|
|
||||||
if (part.type === "text") {
|
|
||||||
msg ??= "msg_1"
|
|
||||||
if (!hasMsg) {
|
|
||||||
hasMsg = true
|
|
||||||
seq += 1
|
|
||||||
lines.push(responseMessage(msg, seq))
|
|
||||||
}
|
|
||||||
seq += 1
|
|
||||||
lines.push(responseText(msg, part.text, seq))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part.type === "reason") {
|
|
||||||
reason ||= "rs_1"
|
|
||||||
if (!hasReason) {
|
|
||||||
hasReason = true
|
|
||||||
seq += 1
|
|
||||||
lines.push(responseReason(reason, seq))
|
|
||||||
seq += 1
|
|
||||||
lines.push(responseReasonPart(reason, seq))
|
|
||||||
}
|
|
||||||
seq += 1
|
|
||||||
lines.push(responseReasonText(reason, part.text, seq))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part.type === "tool-start") {
|
|
||||||
call ||= { id: part.id, item: "fc_1", name: part.name, args: "" }
|
|
||||||
seq += 1
|
|
||||||
lines.push(responseTool(call.id, call.item, call.name, seq))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part.type === "tool-args") {
|
|
||||||
if (!call) continue
|
|
||||||
call.args += part.text
|
|
||||||
seq += 1
|
|
||||||
lines.push(responseToolArgs(call.item, part.text, seq))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
usage = part.usage
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg) {
|
|
||||||
seq += 1
|
|
||||||
lines.push(responseMessageDone(msg, seq))
|
|
||||||
}
|
|
||||||
if (reason) {
|
|
||||||
seq += 1
|
|
||||||
lines.push(responseReasonDone(reason, seq))
|
|
||||||
}
|
|
||||||
if (call && !item.hang && !item.error) {
|
|
||||||
seq += 1
|
|
||||||
lines.push(responseToolDone(call, seq))
|
|
||||||
}
|
|
||||||
if (!item.hang && !item.error) lines.push(responseCompleted({ seq: seq + 1, usage }))
|
|
||||||
return { ...item, head: lines, tail: [] } satisfies Sse
|
|
||||||
}
|
|
||||||
|
|
||||||
function modelFrom(body: unknown) {
|
|
||||||
if (!body || typeof body !== "object") return "test-model"
|
|
||||||
if (!("model" in body) || typeof body.model !== "string") return "test-model"
|
|
||||||
return body.model
|
|
||||||
}
|
|
||||||
|
|
||||||
function send(item: Sse) {
|
|
||||||
const head = bytes(item.head)
|
|
||||||
const tail = bytes([...item.tail, ...(item.hang || item.error ? [] : [done])])
|
|
||||||
const empty = Stream.fromIterable<Uint8Array>([])
|
|
||||||
const wait = item.wait
|
|
||||||
const body: Stream.Stream<Uint8Array, unknown> = wait
|
|
||||||
? Stream.concat(head, Stream.fromEffect(Effect.promise(() => wait)).pipe(Stream.flatMap(() => tail)))
|
|
||||||
: Stream.concat(head, tail)
|
|
||||||
let end: Stream.Stream<Uint8Array, unknown> = empty
|
|
||||||
if (item.error) end = Stream.concat(empty, Stream.fail(item.error))
|
|
||||||
else if (item.hang) end = Stream.concat(empty, Stream.never)
|
|
||||||
|
|
||||||
return HttpServerResponse.stream(Stream.concat(body, end), { contentType: "text/event-stream" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const reset = Effect.fn("TestLLMServer.reset")(function* (item: Sse) {
|
|
||||||
const req = yield* HttpServerRequest.HttpServerRequest
|
|
||||||
const res = NodeHttpServerRequest.toServerResponse(req)
|
|
||||||
yield* Effect.sync(() => {
|
|
||||||
res.writeHead(200, { "content-type": "text/event-stream" })
|
|
||||||
for (const part of item.head) res.write(line(part))
|
|
||||||
for (const part of item.tail) res.write(line(part))
|
|
||||||
res.destroy(new Error("connection reset"))
|
|
||||||
})
|
|
||||||
return yield* Effect.never
|
|
||||||
})
|
|
||||||
|
|
||||||
function fail(item: HttpError) {
|
|
||||||
return HttpServerResponse.text(JSON.stringify(item.body), {
|
|
||||||
status: item.status,
|
|
||||||
contentType: "application/json",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Reply {
|
|
||||||
#head: unknown[] = [role()]
|
|
||||||
#tail: unknown[] = []
|
|
||||||
#usage: Usage | undefined
|
|
||||||
#finish: string | undefined
|
|
||||||
#wait: PromiseLike<unknown> | undefined
|
|
||||||
#hang = false
|
|
||||||
#error: unknown
|
|
||||||
#reset = false
|
|
||||||
#seq = 0
|
|
||||||
|
|
||||||
#id() {
|
|
||||||
this.#seq += 1
|
|
||||||
return `call_${this.#seq}`
|
|
||||||
}
|
|
||||||
|
|
||||||
text(value: string) {
|
|
||||||
this.#tail = [...this.#tail, textLine(value)]
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
reason(value: string) {
|
|
||||||
this.#tail = [...this.#tail, reasonLine(value)]
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
usage(value: Usage) {
|
|
||||||
this.#usage = value
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
wait(value: PromiseLike<unknown>) {
|
|
||||||
this.#wait = value
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
this.#finish = "stop"
|
|
||||||
this.#hang = false
|
|
||||||
this.#error = undefined
|
|
||||||
this.#reset = false
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
toolCalls() {
|
|
||||||
this.#finish = "tool_calls"
|
|
||||||
this.#hang = false
|
|
||||||
this.#error = undefined
|
|
||||||
this.#reset = false
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
tool(name: string, input: unknown) {
|
|
||||||
const id = this.#id()
|
|
||||||
const args = JSON.stringify(input)
|
|
||||||
this.#tail = [...this.#tail, toolStartLine(id, name), toolArgsLine(args)]
|
|
||||||
return this.toolCalls()
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingTool(name: string, input: unknown) {
|
|
||||||
const id = this.#id()
|
|
||||||
const args = JSON.stringify(input)
|
|
||||||
const size = Math.max(1, Math.floor(args.length / 2))
|
|
||||||
this.#tail = [...this.#tail, toolStartLine(id, name), toolArgsLine(args.slice(0, size))]
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
hang() {
|
|
||||||
this.#finish = undefined
|
|
||||||
this.#hang = true
|
|
||||||
this.#error = undefined
|
|
||||||
this.#reset = false
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
streamError(error: unknown = "boom") {
|
|
||||||
this.#finish = undefined
|
|
||||||
this.#hang = false
|
|
||||||
this.#error = error
|
|
||||||
this.#reset = false
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.#finish = undefined
|
|
||||||
this.#hang = false
|
|
||||||
this.#error = undefined
|
|
||||||
this.#reset = true
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
item(): Item {
|
|
||||||
return {
|
|
||||||
type: "sse",
|
|
||||||
head: this.#head,
|
|
||||||
tail: this.#finish ? [...this.#tail, finishLine(this.#finish, this.#usage)] : this.#tail,
|
|
||||||
wait: this.#wait,
|
|
||||||
hang: this.#hang,
|
|
||||||
error: this.#error,
|
|
||||||
reset: this.#reset,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reply() {
|
|
||||||
return new Reply()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function httpError(status: number, body: unknown): Item {
|
|
||||||
return {
|
|
||||||
type: "http-error",
|
|
||||||
status,
|
|
||||||
body,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function raw(input: {
|
|
||||||
chunks?: unknown[]
|
|
||||||
head?: unknown[]
|
|
||||||
tail?: unknown[]
|
|
||||||
wait?: PromiseLike<unknown>
|
|
||||||
hang?: boolean
|
|
||||||
error?: unknown
|
|
||||||
reset?: boolean
|
|
||||||
}): Item {
|
|
||||||
return {
|
|
||||||
type: "sse",
|
|
||||||
head: input.head ?? input.chunks ?? [],
|
|
||||||
tail: input.tail ?? [],
|
|
||||||
wait: input.wait,
|
|
||||||
hang: input.hang,
|
|
||||||
error: input.error,
|
|
||||||
reset: input.reset,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function item(input: Item | Reply) {
|
|
||||||
return input instanceof Reply ? input.item() : input
|
|
||||||
}
|
|
||||||
|
|
||||||
function hit(url: string, body: unknown) {
|
|
||||||
return {
|
|
||||||
url: new URL(url, "http://localhost"),
|
|
||||||
body: body && typeof body === "object" ? (body as Record<string, unknown>) : {},
|
|
||||||
} satisfies Hit
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace TestLLMServer {
|
|
||||||
export interface Service {
|
|
||||||
readonly url: string
|
|
||||||
readonly push: (...input: (Item | Reply)[]) => Effect.Effect<void>
|
|
||||||
readonly pushMatch: (match: Match, ...input: (Item | Reply)[]) => Effect.Effect<void>
|
|
||||||
readonly textMatch: (match: Match, value: string, opts?: { usage?: Usage }) => Effect.Effect<void>
|
|
||||||
readonly toolMatch: (match: Match, name: string, input: unknown) => Effect.Effect<void>
|
|
||||||
readonly text: (value: string, opts?: { usage?: Usage }) => Effect.Effect<void>
|
|
||||||
readonly tool: (name: string, input: unknown) => Effect.Effect<void>
|
|
||||||
readonly toolHang: (name: string, input: unknown) => Effect.Effect<void>
|
|
||||||
readonly reason: (value: string, opts?: { text?: string; usage?: Usage }) => Effect.Effect<void>
|
|
||||||
readonly fail: (message?: unknown) => Effect.Effect<void>
|
|
||||||
readonly error: (status: number, body: unknown) => Effect.Effect<void>
|
|
||||||
readonly hang: Effect.Effect<void>
|
|
||||||
readonly hold: (value: string, wait: PromiseLike<unknown>) => Effect.Effect<void>
|
|
||||||
readonly hits: Effect.Effect<Hit[]>
|
|
||||||
readonly calls: Effect.Effect<number>
|
|
||||||
readonly wait: (count: number) => Effect.Effect<void>
|
|
||||||
readonly inputs: Effect.Effect<Record<string, unknown>[]>
|
|
||||||
readonly pending: Effect.Effect<number>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServer.Service>()("@test/LLMServer") {
|
|
||||||
static readonly layer = Layer.effect(
|
|
||||||
TestLLMServer,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const server = yield* HttpServer.HttpServer
|
|
||||||
const router = yield* HttpRouter.HttpRouter
|
|
||||||
|
|
||||||
let hits: Hit[] = []
|
|
||||||
let list: Queue[] = []
|
|
||||||
let waits: Wait[] = []
|
|
||||||
|
|
||||||
const queue = (...input: (Item | Reply)[]) => {
|
|
||||||
list = [...list, ...input.map((value) => ({ item: item(value) }))]
|
|
||||||
}
|
|
||||||
|
|
||||||
const queueMatch = (match: Match, ...input: (Item | Reply)[]) => {
|
|
||||||
list = [...list, ...input.map((value) => ({ item: item(value), match }))]
|
|
||||||
}
|
|
||||||
|
|
||||||
const notify = Effect.fnUntraced(function* () {
|
|
||||||
const ready = waits.filter((item) => hits.length >= item.count)
|
|
||||||
if (!ready.length) return
|
|
||||||
waits = waits.filter((item) => hits.length < item.count)
|
|
||||||
yield* Effect.forEach(ready, (item) => Deferred.succeed(item.ready, void 0))
|
|
||||||
})
|
|
||||||
|
|
||||||
const pull = (hit: Hit) => {
|
|
||||||
const index = list.findIndex((entry) => !entry.match || entry.match(hit))
|
|
||||||
if (index === -1) return
|
|
||||||
const first = list[index]
|
|
||||||
list = [...list.slice(0, index), ...list.slice(index + 1)]
|
|
||||||
return first.item
|
|
||||||
}
|
|
||||||
|
|
||||||
const handle = Effect.fn("TestLLMServer.handle")(function* (mode: "chat" | "responses") {
|
|
||||||
const req = yield* HttpServerRequest.HttpServerRequest
|
|
||||||
const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
|
|
||||||
const current = hit(req.originalUrl, body)
|
|
||||||
const next = pull(current)
|
|
||||||
if (!next) return HttpServerResponse.text("unexpected request", { status: 500 })
|
|
||||||
hits = [...hits, current]
|
|
||||||
yield* notify()
|
|
||||||
if (next.type !== "sse") return fail(next)
|
|
||||||
if (mode === "responses") return send(responses(next, modelFrom(body)))
|
|
||||||
if (next.reset) {
|
|
||||||
yield* reset(next)
|
|
||||||
return HttpServerResponse.empty()
|
|
||||||
}
|
|
||||||
return send(next)
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* router.add("POST", "/v1/chat/completions", handle("chat"))
|
|
||||||
yield* router.add("POST", "/v1/responses", handle("responses"))
|
|
||||||
|
|
||||||
yield* server.serve(router.asHttpEffect())
|
|
||||||
|
|
||||||
return TestLLMServer.of({
|
|
||||||
url:
|
|
||||||
server.address._tag === "TcpAddress"
|
|
||||||
? `http://127.0.0.1:${server.address.port}/v1`
|
|
||||||
: `unix://${server.address.path}/v1`,
|
|
||||||
push: Effect.fn("TestLLMServer.push")(function* (...input: (Item | Reply)[]) {
|
|
||||||
queue(...input)
|
|
||||||
}),
|
|
||||||
pushMatch: Effect.fn("TestLLMServer.pushMatch")(function* (match: Match, ...input: (Item | Reply)[]) {
|
|
||||||
queueMatch(match, ...input)
|
|
||||||
}),
|
|
||||||
textMatch: Effect.fn("TestLLMServer.textMatch")(function* (
|
|
||||||
match: Match,
|
|
||||||
value: string,
|
|
||||||
opts?: { usage?: Usage },
|
|
||||||
) {
|
|
||||||
const out = reply().text(value)
|
|
||||||
if (opts?.usage) out.usage(opts.usage)
|
|
||||||
queueMatch(match, out.stop().item())
|
|
||||||
}),
|
|
||||||
toolMatch: Effect.fn("TestLLMServer.toolMatch")(function* (match: Match, name: string, input: unknown) {
|
|
||||||
queueMatch(match, reply().tool(name, input).item())
|
|
||||||
}),
|
|
||||||
text: Effect.fn("TestLLMServer.text")(function* (value: string, opts?: { usage?: Usage }) {
|
|
||||||
const out = reply().text(value)
|
|
||||||
if (opts?.usage) out.usage(opts.usage)
|
|
||||||
queue(out.stop().item())
|
|
||||||
}),
|
|
||||||
tool: Effect.fn("TestLLMServer.tool")(function* (name: string, input: unknown) {
|
|
||||||
queue(reply().tool(name, input).item())
|
|
||||||
}),
|
|
||||||
toolHang: Effect.fn("TestLLMServer.toolHang")(function* (name: string, input: unknown) {
|
|
||||||
queue(reply().pendingTool(name, input).hang().item())
|
|
||||||
}),
|
|
||||||
reason: Effect.fn("TestLLMServer.reason")(function* (value: string, opts?: { text?: string; usage?: Usage }) {
|
|
||||||
const out = reply().reason(value)
|
|
||||||
if (opts?.text) out.text(opts.text)
|
|
||||||
if (opts?.usage) out.usage(opts.usage)
|
|
||||||
queue(out.stop().item())
|
|
||||||
}),
|
|
||||||
fail: Effect.fn("TestLLMServer.fail")(function* (message: unknown = "boom") {
|
|
||||||
queue(reply().streamError(message).item())
|
|
||||||
}),
|
|
||||||
error: Effect.fn("TestLLMServer.error")(function* (status: number, body: unknown) {
|
|
||||||
queue(httpError(status, body))
|
|
||||||
}),
|
|
||||||
hang: Effect.gen(function* () {
|
|
||||||
queue(reply().hang().item())
|
|
||||||
}).pipe(Effect.withSpan("TestLLMServer.hang")),
|
|
||||||
hold: Effect.fn("TestLLMServer.hold")(function* (value: string, wait: PromiseLike<unknown>) {
|
|
||||||
queue(reply().wait(wait).text(value).stop().item())
|
|
||||||
}),
|
|
||||||
hits: Effect.sync(() => [...hits]),
|
|
||||||
calls: Effect.sync(() => hits.length),
|
|
||||||
wait: Effect.fn("TestLLMServer.wait")(function* (count: number) {
|
|
||||||
if (hits.length >= count) return
|
|
||||||
const ready = yield* Deferred.make<void>()
|
|
||||||
waits = [...waits, { count, ready }]
|
|
||||||
yield* Deferred.await(ready)
|
|
||||||
}),
|
|
||||||
inputs: Effect.sync(() => hits.map((hit) => hit.body)),
|
|
||||||
pending: Effect.sync(() => list.length),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
).pipe(Layer.provide(HttpRouter.layer), Layer.provide(NodeHttpServer.layer(() => Http.createServer(), { port: 0 })))
|
|
||||||
}
|
|
||||||
@@ -1557,35 +1557,6 @@ describe("ProviderTransform.message - providerOptions key remapping", () => {
|
|||||||
expect(result[0].providerOptions?.openai).toBeUndefined()
|
expect(result[0].providerOptions?.openai).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("azure cognitive services remaps providerID to 'azure' key", () => {
|
|
||||||
const model = createModel("azure-cognitive-services", "@ai-sdk/azure")
|
|
||||||
const msgs = [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "Hello",
|
|
||||||
providerOptions: {
|
|
||||||
"azure-cognitive-services": { part: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
providerOptions: {
|
|
||||||
"azure-cognitive-services": { someOption: "value" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
] as any[]
|
|
||||||
|
|
||||||
const result = ProviderTransform.message(msgs, model, {}) as any[]
|
|
||||||
const part = result[0].content[0] as any
|
|
||||||
|
|
||||||
expect(result[0].providerOptions?.azure).toEqual({ someOption: "value" })
|
|
||||||
expect(result[0].providerOptions?.["azure-cognitive-services"]).toBeUndefined()
|
|
||||||
expect(part.providerOptions?.azure).toEqual({ part: true })
|
|
||||||
expect(part.providerOptions?.["azure-cognitive-services"]).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("copilot remaps providerID to 'copilot' key", () => {
|
test("copilot remaps providerID to 'copilot' key", () => {
|
||||||
const model = createModel("github-copilot", "@ai-sdk/github-copilot")
|
const model = createModel("github-copilot", "@ai-sdk/github-copilot")
|
||||||
const msgs = [
|
const msgs = [
|
||||||
@@ -1792,58 +1763,6 @@ describe("ProviderTransform.message - cache control on gateway", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("google-vertex-anthropic applies cache control", () => {
|
|
||||||
const model = createModel({
|
|
||||||
providerID: "google-vertex-anthropic",
|
|
||||||
api: {
|
|
||||||
id: "google-vertex-anthropic",
|
|
||||||
url: "https://us-central1-aiplatform.googleapis.com",
|
|
||||||
npm: "@ai-sdk/google-vertex/anthropic",
|
|
||||||
},
|
|
||||||
id: "claude-sonnet-4@20250514",
|
|
||||||
})
|
|
||||||
const msgs = [
|
|
||||||
{
|
|
||||||
role: "system",
|
|
||||||
content: "You are a helpful assistant",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: "Hello",
|
|
||||||
},
|
|
||||||
] as any[]
|
|
||||||
|
|
||||||
const result = ProviderTransform.message(msgs, model, {}) as any[]
|
|
||||||
|
|
||||||
expect(result[0].providerOptions).toEqual({
|
|
||||||
anthropic: {
|
|
||||||
cacheControl: {
|
|
||||||
type: "ephemeral",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
openrouter: {
|
|
||||||
cacheControl: {
|
|
||||||
type: "ephemeral",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
bedrock: {
|
|
||||||
cachePoint: {
|
|
||||||
type: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
openaiCompatible: {
|
|
||||||
cache_control: {
|
|
||||||
type: "ephemeral",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
copilot: {
|
|
||||||
copilot_cache_control: {
|
|
||||||
type: "ephemeral",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("ProviderTransform.variants", () => {
|
describe("ProviderTransform.variants", () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterEach, describe, expect, mock, test } from "bun:test"
|
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||||
import { APICallError } from "ai"
|
import { APICallError } from "ai"
|
||||||
import { Cause, Effect, Exit, Layer, ManagedRuntime } from "effect"
|
import { Cause, Effect, Exit, Layer, ManagedRuntime } from "effect"
|
||||||
import * as Stream from "effect/Stream"
|
import * as Stream from "effect/Stream"
|
||||||
@@ -20,9 +20,9 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
|||||||
import { SessionStatus } from "../../src/session/status"
|
import { SessionStatus } from "../../src/session/status"
|
||||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||||
import type { Provider } from "../../src/provider/provider"
|
import type { Provider } from "../../src/provider/provider"
|
||||||
|
import * as ProviderModule from "../../src/provider/provider"
|
||||||
import * as SessionProcessorModule from "../../src/session/processor"
|
import * as SessionProcessorModule from "../../src/session/processor"
|
||||||
import { Snapshot } from "../../src/snapshot"
|
import { Snapshot } from "../../src/snapshot"
|
||||||
import { ProviderTest } from "../fake/provider"
|
|
||||||
|
|
||||||
Log.init({ print: false })
|
Log.init({ print: false })
|
||||||
|
|
||||||
@@ -65,8 +65,6 @@ function createModel(opts: {
|
|||||||
} as Provider.Model
|
} as Provider.Model
|
||||||
}
|
}
|
||||||
|
|
||||||
const wide = () => ProviderTest.fake({ model: createModel({ context: 100_000, output: 32_000 }) })
|
|
||||||
|
|
||||||
async function user(sessionID: SessionID, text: string) {
|
async function user(sessionID: SessionID, text: string) {
|
||||||
const msg = await Session.updateMessage({
|
const msg = await Session.updateMessage({
|
||||||
id: MessageID.ascending(),
|
id: MessageID.ascending(),
|
||||||
@@ -151,6 +149,7 @@ function fake(
|
|||||||
state: { status: "pending", input: {}, raw: "" },
|
state: { status: "pending", input: {}, raw: "" },
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
metadata: Effect.fn("TestSessionProcessor.metadata")(() => Effect.void),
|
||||||
process: Effect.fn("TestSessionProcessor.process")(() => Effect.succeed(result)),
|
process: Effect.fn("TestSessionProcessor.process")(() => Effect.succeed(result)),
|
||||||
} satisfies SessionProcessorModule.SessionProcessor.Handle
|
} satisfies SessionProcessorModule.SessionProcessor.Handle
|
||||||
}
|
}
|
||||||
@@ -164,11 +163,10 @@ function layer(result: "continue" | "compact") {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function runtime(result: "continue" | "compact", plugin = Plugin.defaultLayer, provider = ProviderTest.fake()) {
|
function runtime(result: "continue" | "compact", plugin = Plugin.defaultLayer) {
|
||||||
const bus = Bus.layer
|
const bus = Bus.layer
|
||||||
return ManagedRuntime.make(
|
return ManagedRuntime.make(
|
||||||
Layer.mergeAll(SessionCompaction.layer, bus).pipe(
|
Layer.mergeAll(SessionCompaction.layer, bus).pipe(
|
||||||
Layer.provide(provider.layer),
|
|
||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
Layer.provide(layer(result)),
|
Layer.provide(layer(result)),
|
||||||
Layer.provide(Agent.defaultLayer),
|
Layer.provide(Agent.defaultLayer),
|
||||||
@@ -201,13 +199,12 @@ function llm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function liveRuntime(layer: Layer.Layer<LLM.Service>, provider = ProviderTest.fake()) {
|
function liveRuntime(layer: Layer.Layer<LLM.Service>) {
|
||||||
const bus = Bus.layer
|
const bus = Bus.layer
|
||||||
const status = SessionStatus.layer.pipe(Layer.provide(bus))
|
const status = SessionStatus.layer.pipe(Layer.provide(bus))
|
||||||
const processor = SessionProcessorModule.SessionProcessor.layer
|
const processor = SessionProcessorModule.SessionProcessor.layer
|
||||||
return ManagedRuntime.make(
|
return ManagedRuntime.make(
|
||||||
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe(
|
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe(
|
||||||
Layer.provide(provider.layer),
|
|
||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
Layer.provide(Snapshot.defaultLayer),
|
Layer.provide(Snapshot.defaultLayer),
|
||||||
Layer.provide(layer),
|
Layer.provide(layer),
|
||||||
@@ -548,12 +545,14 @@ describe("session.compaction.process", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
|
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||||
|
|
||||||
const session = await Session.create({})
|
const session = await Session.create({})
|
||||||
const msg = await user(session.id, "hello")
|
const msg = await user(session.id, "hello")
|
||||||
const msgs = await Session.messages({ sessionID: session.id })
|
const msgs = await Session.messages({ sessionID: session.id })
|
||||||
const done = defer()
|
const done = defer()
|
||||||
let seen = false
|
let seen = false
|
||||||
const rt = runtime("continue", Plugin.defaultLayer, wide())
|
const rt = runtime("continue")
|
||||||
let unsub: (() => void) | undefined
|
let unsub: (() => void) | undefined
|
||||||
try {
|
try {
|
||||||
unsub = await rt.runPromise(
|
unsub = await rt.runPromise(
|
||||||
@@ -598,9 +597,11 @@ describe("session.compaction.process", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
|
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||||
|
|
||||||
const session = await Session.create({})
|
const session = await Session.create({})
|
||||||
const msg = await user(session.id, "hello")
|
const msg = await user(session.id, "hello")
|
||||||
const rt = runtime("compact", Plugin.defaultLayer, wide())
|
const rt = runtime("compact")
|
||||||
try {
|
try {
|
||||||
const msgs = await Session.messages({ sessionID: session.id })
|
const msgs = await Session.messages({ sessionID: session.id })
|
||||||
const result = await rt.runPromise(
|
const result = await rt.runPromise(
|
||||||
@@ -636,9 +637,11 @@ describe("session.compaction.process", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
|
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||||
|
|
||||||
const session = await Session.create({})
|
const session = await Session.create({})
|
||||||
const msg = await user(session.id, "hello")
|
const msg = await user(session.id, "hello")
|
||||||
const rt = runtime("continue", Plugin.defaultLayer, wide())
|
const rt = runtime("continue")
|
||||||
try {
|
try {
|
||||||
const msgs = await Session.messages({ sessionID: session.id })
|
const msgs = await Session.messages({ sessionID: session.id })
|
||||||
const result = await rt.runPromise(
|
const result = await rt.runPromise(
|
||||||
@@ -676,6 +679,8 @@ describe("session.compaction.process", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
|
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||||
|
|
||||||
const session = await Session.create({})
|
const session = await Session.create({})
|
||||||
await user(session.id, "root")
|
await user(session.id, "root")
|
||||||
const replay = await user(session.id, "image")
|
const replay = await user(session.id, "image")
|
||||||
@@ -689,7 +694,7 @@ describe("session.compaction.process", () => {
|
|||||||
url: "https://example.com/cat.png",
|
url: "https://example.com/cat.png",
|
||||||
})
|
})
|
||||||
const msg = await user(session.id, "current")
|
const msg = await user(session.id, "current")
|
||||||
const rt = runtime("continue", Plugin.defaultLayer, wide())
|
const rt = runtime("continue")
|
||||||
try {
|
try {
|
||||||
const msgs = await Session.messages({ sessionID: session.id })
|
const msgs = await Session.messages({ sessionID: session.id })
|
||||||
const result = await rt.runPromise(
|
const result = await rt.runPromise(
|
||||||
@@ -724,11 +729,13 @@ describe("session.compaction.process", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
|
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||||
|
|
||||||
const session = await Session.create({})
|
const session = await Session.create({})
|
||||||
await user(session.id, "earlier")
|
await user(session.id, "earlier")
|
||||||
const msg = await user(session.id, "current")
|
const msg = await user(session.id, "current")
|
||||||
|
|
||||||
const rt = runtime("continue", Plugin.defaultLayer, wide())
|
const rt = runtime("continue")
|
||||||
try {
|
try {
|
||||||
const msgs = await Session.messages({ sessionID: session.id })
|
const msgs = await Session.messages({ sessionID: session.id })
|
||||||
const result = await rt.runPromise(
|
const result = await rt.runPromise(
|
||||||
@@ -784,11 +791,13 @@ describe("session.compaction.process", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
|
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||||
|
|
||||||
const session = await Session.create({})
|
const session = await Session.create({})
|
||||||
const msg = await user(session.id, "hello")
|
const msg = await user(session.id, "hello")
|
||||||
const msgs = await Session.messages({ sessionID: session.id })
|
const msgs = await Session.messages({ sessionID: session.id })
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
const rt = liveRuntime(stub.layer, wide())
|
const rt = liveRuntime(stub.layer)
|
||||||
let off: (() => void) | undefined
|
let off: (() => void) | undefined
|
||||||
let run: Promise<"continue" | "stop"> | undefined
|
let run: Promise<"continue" | "stop"> | undefined
|
||||||
try {
|
try {
|
||||||
@@ -858,11 +867,13 @@ describe("session.compaction.process", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
|
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||||
|
|
||||||
const session = await Session.create({})
|
const session = await Session.create({})
|
||||||
const msg = await user(session.id, "hello")
|
const msg = await user(session.id, "hello")
|
||||||
const msgs = await Session.messages({ sessionID: session.id })
|
const msgs = await Session.messages({ sessionID: session.id })
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
const rt = runtime("continue", plugin(ready), wide())
|
const rt = runtime("continue", plugin(ready))
|
||||||
let run: Promise<"continue" | "stop"> | undefined
|
let run: Promise<"continue" | "stop"> | undefined
|
||||||
try {
|
try {
|
||||||
run = rt
|
run = rt
|
||||||
@@ -960,9 +971,11 @@ describe("session.compaction.process", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
|
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||||
|
|
||||||
const session = await Session.create({})
|
const session = await Session.create({})
|
||||||
const msg = await user(session.id, "hello")
|
const msg = await user(session.id, "hello")
|
||||||
const rt = liveRuntime(stub.layer, wide())
|
const rt = liveRuntime(stub.layer)
|
||||||
try {
|
try {
|
||||||
const msgs = await Session.messages({ sessionID: session.id })
|
const msgs = await Session.messages({ sessionID: session.id })
|
||||||
await rt.runPromise(
|
await rt.runPromise(
|
||||||
@@ -1187,26 +1200,4 @@ describe("session.getUsage", () => {
|
|||||||
expect(result.tokens.total).toBe(1500)
|
expect(result.tokens.total).toBe(1500)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
test("extracts cache write tokens from vertex metadata key", () => {
|
|
||||||
const model = createModel({ context: 100_000, output: 32_000, npm: "@ai-sdk/google-vertex/anthropic" })
|
|
||||||
const result = Session.getUsage({
|
|
||||||
model,
|
|
||||||
usage: {
|
|
||||||
inputTokens: 1000,
|
|
||||||
outputTokens: 500,
|
|
||||||
totalTokens: 1500,
|
|
||||||
cachedInputTokens: 200,
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
vertex: {
|
|
||||||
cacheCreationInputTokens: 300,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.tokens.input).toBe(500)
|
|
||||||
expect(result.tokens.cache.read).toBe(200)
|
|
||||||
expect(result.tokens.cache.write).toBe(300)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
|
import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { tool, type ModelMessage } from "ai"
|
import { tool, type ModelMessage } from "ai"
|
||||||
import { Cause, Exit, Stream } from "effect"
|
import { Cause, Effect, Exit, Stream } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { makeRuntime } from "../../src/effect/run-service"
|
import { makeRuntime } from "../../src/effect/run-service"
|
||||||
import { LLM } from "../../src/session/llm"
|
import { LLM } from "../../src/session/llm"
|
||||||
@@ -541,6 +541,94 @@ describe("session.llm.stream", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("service stream preserves fullStream backpressure", async () => {
|
||||||
|
const release = deferred<void>()
|
||||||
|
let pulled = false
|
||||||
|
const mock = spyOn(LLM, "stream").mockResolvedValue({
|
||||||
|
fullStream: {
|
||||||
|
[Symbol.asyncIterator]() {
|
||||||
|
let i = 0
|
||||||
|
return {
|
||||||
|
next: async () => {
|
||||||
|
if (i === 0) {
|
||||||
|
i += 1
|
||||||
|
return { done: false, value: { type: "start" } as LLM.Event }
|
||||||
|
}
|
||||||
|
if (i === 1) {
|
||||||
|
pulled = true
|
||||||
|
await release.promise
|
||||||
|
i += 1
|
||||||
|
return {
|
||||||
|
done: false,
|
||||||
|
value: {
|
||||||
|
type: "finish",
|
||||||
|
finishReason: "stop",
|
||||||
|
rawFinishReason: "stop",
|
||||||
|
totalUsage: {
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
},
|
||||||
|
} as LLM.Event,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { done: true, value: undefined }
|
||||||
|
},
|
||||||
|
return: async () => ({ done: true, value: undefined }),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as Awaited<ReturnType<typeof LLM.stream>>)
|
||||||
|
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
try {
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const sessionID = SessionID.make("session-test-service-backpressure")
|
||||||
|
const { runPromise } = makeRuntime(LLM.Service, LLM.defaultLayer)
|
||||||
|
await runPromise((svc) =>
|
||||||
|
svc
|
||||||
|
.stream({
|
||||||
|
user: {
|
||||||
|
id: MessageID.make("user-service-backpressure"),
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: Date.now() },
|
||||||
|
agent: "test",
|
||||||
|
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||||
|
} satisfies MessageV2.User,
|
||||||
|
sessionID,
|
||||||
|
model: {} as Provider.Model,
|
||||||
|
agent: {
|
||||||
|
name: "test",
|
||||||
|
mode: "primary",
|
||||||
|
options: {},
|
||||||
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||||
|
} satisfies Agent.Info,
|
||||||
|
system: [],
|
||||||
|
messages: [],
|
||||||
|
tools: {},
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
Stream.tap((event) =>
|
||||||
|
event.type === "start"
|
||||||
|
? Effect.sync(() => {
|
||||||
|
expect(pulled).toBe(false)
|
||||||
|
release.resolve()
|
||||||
|
})
|
||||||
|
: Effect.void,
|
||||||
|
),
|
||||||
|
Stream.runDrain,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
mock.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("keeps tools enabled by prompt permissions", async () => {
|
test("keeps tools enabled by prompt permissions", async () => {
|
||||||
const server = state.server
|
const server = state.server
|
||||||
if (!server) {
|
if (!server) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { expect } from "bun:test"
|
import { expect } from "bun:test"
|
||||||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
import { APICallError } from "ai"
|
||||||
|
import { Cause, Effect, Exit, Fiber, Layer, ServiceMap } from "effect"
|
||||||
|
import * as Stream from "effect/Stream"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import type { Agent } from "../../src/agent/agent"
|
import type { Agent } from "../../src/agent/agent"
|
||||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||||
@@ -8,7 +10,7 @@ import { Bus } from "../../src/bus"
|
|||||||
import { Config } from "../../src/config/config"
|
import { Config } from "../../src/config/config"
|
||||||
import { Permission } from "../../src/permission"
|
import { Permission } from "../../src/permission"
|
||||||
import { Plugin } from "../../src/plugin"
|
import { Plugin } from "../../src/plugin"
|
||||||
import { Provider } from "../../src/provider/provider"
|
import type { Provider } from "../../src/provider/provider"
|
||||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||||
import { Session } from "../../src/session"
|
import { Session } from "../../src/session"
|
||||||
import { LLM } from "../../src/session/llm"
|
import { LLM } from "../../src/session/llm"
|
||||||
@@ -19,9 +21,8 @@ import { SessionStatus } from "../../src/session/status"
|
|||||||
import { Snapshot } from "../../src/snapshot"
|
import { Snapshot } from "../../src/snapshot"
|
||||||
import { Log } from "../../src/util/log"
|
import { Log } from "../../src/util/log"
|
||||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
import { provideTmpdirServer } from "../fixture/fixture"
|
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { reply, TestLLMServer } from "../lib/llm-server"
|
|
||||||
|
|
||||||
Log.init({ print: false })
|
Log.init({ print: false })
|
||||||
|
|
||||||
@@ -30,51 +31,118 @@ const ref = {
|
|||||||
modelID: ModelID.make("test-model"),
|
modelID: ModelID.make("test-model"),
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfg = {
|
type Script = Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)
|
||||||
provider: {
|
|
||||||
test: {
|
class TestLLM extends ServiceMap.Service<
|
||||||
name: "Test",
|
TestLLM,
|
||||||
id: "test",
|
{
|
||||||
env: [],
|
readonly push: (stream: Script) => Effect.Effect<void>
|
||||||
npm: "@ai-sdk/openai-compatible",
|
readonly reply: (...items: LLM.Event[]) => Effect.Effect<void>
|
||||||
models: {
|
readonly calls: Effect.Effect<number>
|
||||||
"test-model": {
|
readonly inputs: Effect.Effect<LLM.StreamInput[]>
|
||||||
id: "test-model",
|
}
|
||||||
name: "Test Model",
|
>()("@test/SessionProcessorLLM") {}
|
||||||
attachment: false,
|
|
||||||
reasoning: false,
|
function stream(...items: LLM.Event[]) {
|
||||||
temperature: false,
|
return Stream.make(...items)
|
||||||
tool_call: true,
|
|
||||||
release_date: "2025-01-01",
|
|
||||||
limit: { context: 100000, output: 10000 },
|
|
||||||
cost: { input: 0, output: 0 },
|
|
||||||
options: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
apiKey: "test-key",
|
|
||||||
baseURL: "http://localhost:1/v1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function providerCfg(url: string) {
|
function usage(input = 1, output = 1, total = input + output) {
|
||||||
return {
|
return {
|
||||||
...cfg,
|
inputTokens: input,
|
||||||
provider: {
|
outputTokens: output,
|
||||||
...cfg.provider,
|
totalTokens: total,
|
||||||
test: {
|
inputTokenDetails: {
|
||||||
...cfg.provider.test,
|
noCacheTokens: undefined,
|
||||||
options: {
|
cacheReadTokens: undefined,
|
||||||
...cfg.provider.test.options,
|
cacheWriteTokens: undefined,
|
||||||
baseURL: url,
|
},
|
||||||
},
|
outputTokenDetails: {
|
||||||
},
|
textTokens: undefined,
|
||||||
|
reasoningTokens: undefined,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function start(): LLM.Event {
|
||||||
|
return { type: "start" }
|
||||||
|
}
|
||||||
|
|
||||||
|
function textStart(id = "t"): LLM.Event {
|
||||||
|
return { type: "text-start", id }
|
||||||
|
}
|
||||||
|
|
||||||
|
function textDelta(id: string, text: string): LLM.Event {
|
||||||
|
return { type: "text-delta", id, text }
|
||||||
|
}
|
||||||
|
|
||||||
|
function textEnd(id = "t"): LLM.Event {
|
||||||
|
return { type: "text-end", id }
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasoningStart(id: string): LLM.Event {
|
||||||
|
return { type: "reasoning-start", id }
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasoningDelta(id: string, text: string): LLM.Event {
|
||||||
|
return { type: "reasoning-delta", id, text }
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasoningEnd(id: string): LLM.Event {
|
||||||
|
return { type: "reasoning-end", id }
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishStep(): LLM.Event {
|
||||||
|
return {
|
||||||
|
type: "finish-step",
|
||||||
|
finishReason: "stop",
|
||||||
|
rawFinishReason: "stop",
|
||||||
|
response: { id: "res", modelId: "test-model", timestamp: new Date() },
|
||||||
|
providerMetadata: undefined,
|
||||||
|
usage: usage(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(): LLM.Event {
|
||||||
|
return { type: "finish", finishReason: "stop", rawFinishReason: "stop", totalUsage: usage() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolInputStart(id: string, toolName: string): LLM.Event {
|
||||||
|
return { type: "tool-input-start", id, toolName }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolCall(toolCallId: string, toolName: string, input: unknown): LLM.Event {
|
||||||
|
return { type: "tool-call", toolCallId, toolName, input }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail<E>(err: E, ...items: LLM.Event[]) {
|
||||||
|
return stream(...items).pipe(Stream.concat(Stream.fail(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function hang(_input: LLM.StreamInput, ...items: LLM.Event[]) {
|
||||||
|
return stream(...items).pipe(Stream.concat(Stream.fromEffect(Effect.never)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function model(context: number): Provider.Model {
|
||||||
|
return {
|
||||||
|
id: "test-model",
|
||||||
|
providerID: "test",
|
||||||
|
name: "Test",
|
||||||
|
limit: { context, output: 10 },
|
||||||
|
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||||
|
capabilities: {
|
||||||
|
toolcall: true,
|
||||||
|
attachment: false,
|
||||||
|
reasoning: false,
|
||||||
|
temperature: true,
|
||||||
|
input: { text: true, image: false, audio: false, video: false },
|
||||||
|
output: { text: true, image: false, audio: false, video: false },
|
||||||
|
},
|
||||||
|
api: { npm: "@ai-sdk/anthropic" },
|
||||||
|
options: {},
|
||||||
|
} as Provider.Model
|
||||||
|
}
|
||||||
|
|
||||||
function agent(): Agent.Info {
|
function agent(): Agent.Info {
|
||||||
return {
|
return {
|
||||||
name: "build",
|
name: "build",
|
||||||
@@ -143,6 +211,43 @@ const assistant = Effect.fn("TestSession.assistant")(function* (
|
|||||||
return msg
|
return msg
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const llm = Layer.unwrap(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const queue: Script[] = []
|
||||||
|
const inputs: LLM.StreamInput[] = []
|
||||||
|
let calls = 0
|
||||||
|
|
||||||
|
const push = Effect.fn("TestLLM.push")((item: Script) => {
|
||||||
|
queue.push(item)
|
||||||
|
return Effect.void
|
||||||
|
})
|
||||||
|
|
||||||
|
const reply = Effect.fn("TestLLM.reply")((...items: LLM.Event[]) => push(stream(...items)))
|
||||||
|
return Layer.mergeAll(
|
||||||
|
Layer.succeed(
|
||||||
|
LLM.Service,
|
||||||
|
LLM.Service.of({
|
||||||
|
stream: (input) => {
|
||||||
|
calls += 1
|
||||||
|
inputs.push(input)
|
||||||
|
const item = queue.shift() ?? Stream.empty
|
||||||
|
return typeof item === "function" ? item(input) : item
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Layer.succeed(
|
||||||
|
TestLLM,
|
||||||
|
TestLLM.of({
|
||||||
|
push,
|
||||||
|
reply,
|
||||||
|
calls: Effect.sync(() => calls),
|
||||||
|
inputs: Effect.sync(() => [...inputs]),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
|
const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
|
||||||
const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
|
const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
|
||||||
const deps = Layer.mergeAll(
|
const deps = Layer.mergeAll(
|
||||||
@@ -152,37 +257,27 @@ const deps = Layer.mergeAll(
|
|||||||
Permission.layer,
|
Permission.layer,
|
||||||
Plugin.defaultLayer,
|
Plugin.defaultLayer,
|
||||||
Config.defaultLayer,
|
Config.defaultLayer,
|
||||||
LLM.defaultLayer,
|
|
||||||
Provider.defaultLayer,
|
|
||||||
status,
|
status,
|
||||||
|
llm,
|
||||||
).pipe(Layer.provideMerge(infra))
|
).pipe(Layer.provideMerge(infra))
|
||||||
const env = Layer.mergeAll(TestLLMServer.layer, SessionProcessor.layer.pipe(Layer.provideMerge(deps)))
|
const env = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
|
||||||
|
|
||||||
const it = testEffect(env)
|
const it = testEffect(env)
|
||||||
|
|
||||||
const boot = Effect.fn("test.boot")(function* () {
|
it.effect("session.processor effect tests capture llm input cleanly", () => {
|
||||||
const processors = yield* SessionProcessor.Service
|
return provideTmpdirInstance(
|
||||||
const session = yield* Session.Service
|
(dir) =>
|
||||||
const provider = yield* Provider.Service
|
|
||||||
return { processors, session, provider }
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
it.live("session.processor effect tests capture llm input cleanly", () =>
|
|
||||||
provideTmpdirServer(
|
|
||||||
({ dir, llm }) =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { processors, session, provider } = yield* boot()
|
const test = yield* TestLLM
|
||||||
|
const processors = yield* SessionProcessor.Service
|
||||||
|
const session = yield* Session.Service
|
||||||
|
|
||||||
yield* llm.text("hello")
|
yield* test.reply(start(), textStart(), textDelta("t", "hello"), textEnd(), finishStep(), finish())
|
||||||
|
|
||||||
const chat = yield* session.create({})
|
const chat = yield* session.create({})
|
||||||
const parent = yield* user(chat.id, "hi")
|
const parent = yield* user(chat.id, "hi")
|
||||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
const mdl = model(100)
|
||||||
const handle = yield* processors.create({
|
const handle = yield* processors.create({
|
||||||
assistantMessage: msg,
|
assistantMessage: msg,
|
||||||
sessionID: chat.id,
|
sessionID: chat.id,
|
||||||
@@ -208,29 +303,46 @@ it.live("session.processor effect tests capture llm input cleanly", () =>
|
|||||||
|
|
||||||
const value = yield* handle.process(input)
|
const value = yield* handle.process(input)
|
||||||
const parts = yield* Effect.promise(() => MessageV2.parts(msg.id))
|
const parts = yield* Effect.promise(() => MessageV2.parts(msg.id))
|
||||||
const calls = yield* llm.calls
|
const calls = yield* test.calls
|
||||||
|
const inputs = yield* test.inputs
|
||||||
|
|
||||||
expect(value).toBe("continue")
|
expect(value).toBe("continue")
|
||||||
expect(calls).toBe(1)
|
expect(calls).toBe(1)
|
||||||
|
expect(inputs).toHaveLength(1)
|
||||||
|
expect(inputs[0].messages).toStrictEqual([{ role: "user", content: "hi" }])
|
||||||
expect(parts.some((part) => part.type === "text" && part.text === "hello")).toBe(true)
|
expect(parts.some((part) => part.type === "text" && part.text === "hello")).toBe(true)
|
||||||
}),
|
}),
|
||||||
{ git: true, config: (url) => providerCfg(url) },
|
{ git: true },
|
||||||
),
|
)
|
||||||
)
|
})
|
||||||
|
|
||||||
it.live("session.processor effect tests stop after token overflow requests compaction", () =>
|
it.effect("session.processor effect tests stop after token overflow requests compaction", () => {
|
||||||
provideTmpdirServer(
|
return provideTmpdirInstance(
|
||||||
({ dir, llm }) =>
|
(dir) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { processors, session, provider } = yield* boot()
|
const test = yield* TestLLM
|
||||||
|
const processors = yield* SessionProcessor.Service
|
||||||
|
const session = yield* Session.Service
|
||||||
|
|
||||||
yield* llm.text("after", { usage: { input: 100, output: 0 } })
|
yield* test.reply(
|
||||||
|
start(),
|
||||||
|
{
|
||||||
|
type: "finish-step",
|
||||||
|
finishReason: "stop",
|
||||||
|
rawFinishReason: "stop",
|
||||||
|
response: { id: "res", modelId: "test-model", timestamp: new Date() },
|
||||||
|
providerMetadata: undefined,
|
||||||
|
usage: usage(100, 0, 100),
|
||||||
|
},
|
||||||
|
textStart(),
|
||||||
|
textDelta("t", "after"),
|
||||||
|
textEnd(),
|
||||||
|
)
|
||||||
|
|
||||||
const chat = yield* session.create({})
|
const chat = yield* session.create({})
|
||||||
const parent = yield* user(chat.id, "compact")
|
const parent = yield* user(chat.id, "compact")
|
||||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||||
const base = yield* provider.getModel(ref.providerID, ref.modelID)
|
const mdl = model(20)
|
||||||
const mdl = { ...base, limit: { context: 20, output: 10 } }
|
|
||||||
const handle = yield* processors.create({
|
const handle = yield* processors.create({
|
||||||
assistantMessage: msg,
|
assistantMessage: msg,
|
||||||
sessionID: chat.id,
|
sessionID: chat.id,
|
||||||
@@ -257,73 +369,51 @@ it.live("session.processor effect tests stop after token overflow requests compa
|
|||||||
const parts = yield* Effect.promise(() => MessageV2.parts(msg.id))
|
const parts = yield* Effect.promise(() => MessageV2.parts(msg.id))
|
||||||
|
|
||||||
expect(value).toBe("compact")
|
expect(value).toBe("compact")
|
||||||
expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true)
|
expect(parts.some((part) => part.type === "text")).toBe(false)
|
||||||
expect(parts.some((part) => part.type === "step-finish")).toBe(true)
|
expect(parts.some((part) => part.type === "step-finish")).toBe(true)
|
||||||
}),
|
}),
|
||||||
{ git: true, config: (url) => providerCfg(url) },
|
{ git: true },
|
||||||
),
|
)
|
||||||
)
|
})
|
||||||
|
|
||||||
it.live("session.processor effect tests capture reasoning from http mock", () =>
|
it.effect("session.processor effect tests reset reasoning state across retries", () => {
|
||||||
provideTmpdirServer(
|
return provideTmpdirInstance(
|
||||||
({ dir, llm }) =>
|
(dir) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { processors, session, provider } = yield* boot()
|
const test = yield* TestLLM
|
||||||
|
const processors = yield* SessionProcessor.Service
|
||||||
|
const session = yield* Session.Service
|
||||||
|
|
||||||
yield* llm.push(reply().reason("think").text("done").stop())
|
yield* test.push(
|
||||||
|
fail(
|
||||||
|
new APICallError({
|
||||||
|
message: "boom",
|
||||||
|
url: "https://example.com/v1/chat/completions",
|
||||||
|
requestBodyValues: {},
|
||||||
|
statusCode: 503,
|
||||||
|
responseHeaders: { "retry-after-ms": "0" },
|
||||||
|
responseBody: '{"error":"boom"}',
|
||||||
|
isRetryable: true,
|
||||||
|
}),
|
||||||
|
start(),
|
||||||
|
reasoningStart("r"),
|
||||||
|
reasoningDelta("r", "one"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* test.reply(
|
||||||
|
start(),
|
||||||
|
reasoningStart("r"),
|
||||||
|
reasoningDelta("r", "two"),
|
||||||
|
reasoningEnd("r"),
|
||||||
|
finishStep(),
|
||||||
|
finish(),
|
||||||
|
)
|
||||||
|
|
||||||
const chat = yield* session.create({})
|
const chat = yield* session.create({})
|
||||||
const parent = yield* user(chat.id, "reason")
|
const parent = yield* user(chat.id, "reason")
|
||||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
const mdl = model(100)
|
||||||
const handle = yield* processors.create({
|
|
||||||
assistantMessage: msg,
|
|
||||||
sessionID: chat.id,
|
|
||||||
model: mdl,
|
|
||||||
})
|
|
||||||
|
|
||||||
const value = yield* handle.process({
|
|
||||||
user: {
|
|
||||||
id: parent.id,
|
|
||||||
sessionID: chat.id,
|
|
||||||
role: "user",
|
|
||||||
time: parent.time,
|
|
||||||
agent: parent.agent,
|
|
||||||
model: { providerID: ref.providerID, modelID: ref.modelID },
|
|
||||||
} satisfies MessageV2.User,
|
|
||||||
sessionID: chat.id,
|
|
||||||
model: mdl,
|
|
||||||
agent: agent(),
|
|
||||||
system: [],
|
|
||||||
messages: [{ role: "user", content: "reason" }],
|
|
||||||
tools: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
const parts = yield* Effect.promise(() => MessageV2.parts(msg.id))
|
|
||||||
const reasoning = parts.find((part): part is MessageV2.ReasoningPart => part.type === "reasoning")
|
|
||||||
const text = parts.find((part): part is MessageV2.TextPart => part.type === "text")
|
|
||||||
|
|
||||||
expect(value).toBe("continue")
|
|
||||||
expect(yield* llm.calls).toBe(1)
|
|
||||||
expect(reasoning?.text).toBe("think")
|
|
||||||
expect(text?.text).toBe("done")
|
|
||||||
}),
|
|
||||||
{ git: true, config: (url) => providerCfg(url) },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("session.processor effect tests reset reasoning state across retries", () =>
|
|
||||||
provideTmpdirServer(
|
|
||||||
({ dir, llm }) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const { processors, session, provider } = yield* boot()
|
|
||||||
|
|
||||||
yield* llm.push(reply().reason("one").reset(), reply().reason("two").stop())
|
|
||||||
|
|
||||||
const chat = yield* session.create({})
|
|
||||||
const parent = yield* user(chat.id, "reason")
|
|
||||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
|
||||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
|
||||||
const handle = yield* processors.create({
|
const handle = yield* processors.create({
|
||||||
assistantMessage: msg,
|
assistantMessage: msg,
|
||||||
sessionID: chat.id,
|
sessionID: chat.id,
|
||||||
@@ -351,26 +441,28 @@ it.live("session.processor effect tests reset reasoning state across retries", (
|
|||||||
const reasoning = parts.filter((part): part is MessageV2.ReasoningPart => part.type === "reasoning")
|
const reasoning = parts.filter((part): part is MessageV2.ReasoningPart => part.type === "reasoning")
|
||||||
|
|
||||||
expect(value).toBe("continue")
|
expect(value).toBe("continue")
|
||||||
expect(yield* llm.calls).toBe(2)
|
expect(yield* test.calls).toBe(2)
|
||||||
expect(reasoning.some((part) => part.text === "two")).toBe(true)
|
expect(reasoning.some((part) => part.text === "two")).toBe(true)
|
||||||
expect(reasoning.some((part) => part.text === "onetwo")).toBe(false)
|
expect(reasoning.some((part) => part.text === "onetwo")).toBe(false)
|
||||||
}),
|
}),
|
||||||
{ git: true, config: (url) => providerCfg(url) },
|
{ git: true },
|
||||||
),
|
)
|
||||||
)
|
})
|
||||||
|
|
||||||
it.live("session.processor effect tests do not retry unknown json errors", () =>
|
it.effect("session.processor effect tests do not retry unknown json errors", () => {
|
||||||
provideTmpdirServer(
|
return provideTmpdirInstance(
|
||||||
({ dir, llm }) =>
|
(dir) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { processors, session, provider } = yield* boot()
|
const test = yield* TestLLM
|
||||||
|
const processors = yield* SessionProcessor.Service
|
||||||
|
const session = yield* Session.Service
|
||||||
|
|
||||||
yield* llm.error(400, { error: { message: "no_kv_space" } })
|
yield* test.push(fail({ error: { message: "no_kv_space" } }, start()))
|
||||||
|
|
||||||
const chat = yield* session.create({})
|
const chat = yield* session.create({})
|
||||||
const parent = yield* user(chat.id, "json")
|
const parent = yield* user(chat.id, "json")
|
||||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
const mdl = model(100)
|
||||||
const handle = yield* processors.create({
|
const handle = yield* processors.create({
|
||||||
assistantMessage: msg,
|
assistantMessage: msg,
|
||||||
sessionID: chat.id,
|
sessionID: chat.id,
|
||||||
@@ -395,26 +487,29 @@ it.live("session.processor effect tests do not retry unknown json errors", () =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(value).toBe("stop")
|
expect(value).toBe("stop")
|
||||||
expect(yield* llm.calls).toBe(1)
|
expect(yield* test.calls).toBe(1)
|
||||||
expect(handle.message.error?.name).toBe("APIError")
|
expect(yield* test.inputs).toHaveLength(1)
|
||||||
|
expect(handle.message.error?.name).toBe("UnknownError")
|
||||||
}),
|
}),
|
||||||
{ git: true, config: (url) => providerCfg(url) },
|
{ git: true },
|
||||||
),
|
)
|
||||||
)
|
})
|
||||||
|
|
||||||
it.live("session.processor effect tests retry recognized structured json errors", () =>
|
it.effect("session.processor effect tests retry recognized structured json errors", () => {
|
||||||
provideTmpdirServer(
|
return provideTmpdirInstance(
|
||||||
({ dir, llm }) =>
|
(dir) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { processors, session, provider } = yield* boot()
|
const test = yield* TestLLM
|
||||||
|
const processors = yield* SessionProcessor.Service
|
||||||
|
const session = yield* Session.Service
|
||||||
|
|
||||||
yield* llm.error(429, { type: "error", error: { type: "too_many_requests" } })
|
yield* test.push(fail({ type: "error", error: { type: "too_many_requests" } }, start()))
|
||||||
yield* llm.text("after")
|
yield* test.reply(start(), textStart(), textDelta("t", "after"), textEnd(), finishStep(), finish())
|
||||||
|
|
||||||
const chat = yield* session.create({})
|
const chat = yield* session.create({})
|
||||||
const parent = yield* user(chat.id, "retry json")
|
const parent = yield* user(chat.id, "retry json")
|
||||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
const mdl = model(100)
|
||||||
const handle = yield* processors.create({
|
const handle = yield* processors.create({
|
||||||
assistantMessage: msg,
|
assistantMessage: msg,
|
||||||
sessionID: chat.id,
|
sessionID: chat.id,
|
||||||
@@ -441,28 +536,43 @@ it.live("session.processor effect tests retry recognized structured json errors"
|
|||||||
const parts = yield* Effect.promise(() => MessageV2.parts(msg.id))
|
const parts = yield* Effect.promise(() => MessageV2.parts(msg.id))
|
||||||
|
|
||||||
expect(value).toBe("continue")
|
expect(value).toBe("continue")
|
||||||
expect(yield* llm.calls).toBe(2)
|
expect(yield* test.calls).toBe(2)
|
||||||
expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true)
|
expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true)
|
||||||
expect(handle.message.error).toBeUndefined()
|
expect(handle.message.error).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
{ git: true, config: (url) => providerCfg(url) },
|
{ git: true },
|
||||||
),
|
)
|
||||||
)
|
})
|
||||||
|
|
||||||
it.live("session.processor effect tests publish retry status updates", () =>
|
it.effect("session.processor effect tests publish retry status updates", () => {
|
||||||
provideTmpdirServer(
|
return provideTmpdirInstance(
|
||||||
({ dir, llm }) =>
|
(dir) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { processors, session, provider } = yield* boot()
|
const test = yield* TestLLM
|
||||||
|
const processors = yield* SessionProcessor.Service
|
||||||
|
const session = yield* Session.Service
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
|
|
||||||
yield* llm.error(503, { error: "boom" })
|
yield* test.push(
|
||||||
yield* llm.text("")
|
fail(
|
||||||
|
new APICallError({
|
||||||
|
message: "boom",
|
||||||
|
url: "https://example.com/v1/chat/completions",
|
||||||
|
requestBodyValues: {},
|
||||||
|
statusCode: 503,
|
||||||
|
responseHeaders: { "retry-after-ms": "0" },
|
||||||
|
responseBody: '{"error":"boom"}',
|
||||||
|
isRetryable: true,
|
||||||
|
}),
|
||||||
|
start(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
yield* test.reply(start(), finishStep(), finish())
|
||||||
|
|
||||||
const chat = yield* session.create({})
|
const chat = yield* session.create({})
|
||||||
const parent = yield* user(chat.id, "retry")
|
const parent = yield* user(chat.id, "retry")
|
||||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
const mdl = model(100)
|
||||||
const states: number[] = []
|
const states: number[] = []
|
||||||
const off = yield* bus.subscribeCallback(SessionStatus.Event.Status, (evt) => {
|
const off = yield* bus.subscribeCallback(SessionStatus.Event.Status, (evt) => {
|
||||||
if (evt.properties.sessionID !== chat.id) return
|
if (evt.properties.sessionID !== chat.id) return
|
||||||
@@ -494,25 +604,27 @@ it.live("session.processor effect tests publish retry status updates", () =>
|
|||||||
off()
|
off()
|
||||||
|
|
||||||
expect(value).toBe("continue")
|
expect(value).toBe("continue")
|
||||||
expect(yield* llm.calls).toBe(2)
|
expect(yield* test.calls).toBe(2)
|
||||||
expect(states).toStrictEqual([1])
|
expect(states).toStrictEqual([1])
|
||||||
}),
|
}),
|
||||||
{ git: true, config: (url) => providerCfg(url) },
|
{ git: true },
|
||||||
),
|
)
|
||||||
)
|
})
|
||||||
|
|
||||||
it.live("session.processor effect tests compact on structured context overflow", () =>
|
it.effect("session.processor effect tests compact on structured context overflow", () => {
|
||||||
provideTmpdirServer(
|
return provideTmpdirInstance(
|
||||||
({ dir, llm }) =>
|
(dir) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { processors, session, provider } = yield* boot()
|
const test = yield* TestLLM
|
||||||
|
const processors = yield* SessionProcessor.Service
|
||||||
|
const session = yield* Session.Service
|
||||||
|
|
||||||
yield* llm.error(400, { type: "error", error: { code: "context_length_exceeded" } })
|
yield* test.push(fail({ type: "error", error: { code: "context_length_exceeded" } }, start()))
|
||||||
|
|
||||||
const chat = yield* session.create({})
|
const chat = yield* session.create({})
|
||||||
const parent = yield* user(chat.id, "compact json")
|
const parent = yield* user(chat.id, "compact json")
|
||||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
const mdl = model(100)
|
||||||
const handle = yield* processors.create({
|
const handle = yield* processors.create({
|
||||||
assistantMessage: msg,
|
assistantMessage: msg,
|
||||||
sessionID: chat.id,
|
sessionID: chat.id,
|
||||||
@@ -537,25 +649,32 @@ it.live("session.processor effect tests compact on structured context overflow",
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(value).toBe("compact")
|
expect(value).toBe("compact")
|
||||||
expect(yield* llm.calls).toBe(1)
|
expect(yield* test.calls).toBe(1)
|
||||||
expect(handle.message.error).toBeUndefined()
|
expect(handle.message.error).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
{ git: true, config: (url) => providerCfg(url) },
|
{ git: true },
|
||||||
),
|
)
|
||||||
)
|
})
|
||||||
|
|
||||||
it.live("session.processor effect tests mark pending tools as aborted on cleanup", () =>
|
it.effect("session.processor effect tests mark pending tools as aborted on cleanup", () => {
|
||||||
provideTmpdirServer(
|
return provideTmpdirInstance(
|
||||||
({ dir, llm }) =>
|
(dir) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { processors, session, provider } = yield* boot()
|
const ready = defer<void>()
|
||||||
|
const test = yield* TestLLM
|
||||||
|
const processors = yield* SessionProcessor.Service
|
||||||
|
const session = yield* Session.Service
|
||||||
|
|
||||||
yield* llm.toolHang("bash", { cmd: "pwd" })
|
yield* test.push((input) =>
|
||||||
|
hang(input, start(), toolInputStart("tool-1", "bash"), toolCall("tool-1", "bash", { cmd: "pwd" })).pipe(
|
||||||
|
Stream.tap((event) => (event.type === "tool-call" ? Effect.sync(() => ready.resolve()) : Effect.void)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const chat = yield* session.create({})
|
const chat = yield* session.create({})
|
||||||
const parent = yield* user(chat.id, "tool abort")
|
const parent = yield* user(chat.id, "tool abort")
|
||||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
const mdl = model(100)
|
||||||
const handle = yield* processors.create({
|
const handle = yield* processors.create({
|
||||||
assistantMessage: msg,
|
assistantMessage: msg,
|
||||||
sessionID: chat.id,
|
sessionID: chat.id,
|
||||||
@@ -581,15 +700,7 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup
|
|||||||
})
|
})
|
||||||
.pipe(Effect.forkChild)
|
.pipe(Effect.forkChild)
|
||||||
|
|
||||||
yield* llm.wait(1)
|
yield* Effect.promise(() => ready.promise)
|
||||||
yield* Effect.promise(async () => {
|
|
||||||
const end = Date.now() + 500
|
|
||||||
while (Date.now() < end) {
|
|
||||||
const parts = await MessageV2.parts(msg.id)
|
|
||||||
if (parts.some((part) => part.type === "tool")) return
|
|
||||||
await Bun.sleep(10)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
yield* Fiber.interrupt(run)
|
yield* Fiber.interrupt(run)
|
||||||
|
|
||||||
const exit = yield* Fiber.await(run)
|
const exit = yield* Fiber.await(run)
|
||||||
@@ -597,38 +708,45 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup
|
|||||||
yield* handle.abort()
|
yield* handle.abort()
|
||||||
}
|
}
|
||||||
const parts = yield* Effect.promise(() => MessageV2.parts(msg.id))
|
const parts = yield* Effect.promise(() => MessageV2.parts(msg.id))
|
||||||
const call = parts.find((part): part is MessageV2.ToolPart => part.type === "tool")
|
const tool = parts.find((part): part is MessageV2.ToolPart => part.type === "tool")
|
||||||
|
|
||||||
expect(Exit.isFailure(exit)).toBe(true)
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
if (Exit.isFailure(exit)) {
|
if (Exit.isFailure(exit)) {
|
||||||
expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||||
}
|
}
|
||||||
expect(yield* llm.calls).toBe(1)
|
expect(yield* test.calls).toBe(1)
|
||||||
expect(call?.state.status).toBe("error")
|
expect(tool?.state.status).toBe("error")
|
||||||
if (call?.state.status === "error") {
|
if (tool?.state.status === "error") {
|
||||||
expect(call.state.error).toBe("Tool execution aborted")
|
expect(tool.state.error).toBe("Tool execution aborted")
|
||||||
expect(call.state.time.end).toBeDefined()
|
expect(tool.state.time.end).toBeDefined()
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
{ git: true, config: (url) => providerCfg(url) },
|
{ git: true },
|
||||||
),
|
)
|
||||||
)
|
})
|
||||||
|
|
||||||
it.live("session.processor effect tests record aborted errors and idle state", () =>
|
it.effect("session.processor effect tests record aborted errors and idle state", () => {
|
||||||
provideTmpdirServer(
|
return provideTmpdirInstance(
|
||||||
({ dir, llm }) =>
|
(dir) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const ready = defer<void>()
|
||||||
const seen = defer<void>()
|
const seen = defer<void>()
|
||||||
const { processors, session, provider } = yield* boot()
|
const test = yield* TestLLM
|
||||||
|
const processors = yield* SessionProcessor.Service
|
||||||
|
const session = yield* Session.Service
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const sts = yield* SessionStatus.Service
|
const status = yield* SessionStatus.Service
|
||||||
|
|
||||||
yield* llm.hang
|
yield* test.push((input) =>
|
||||||
|
hang(input, start()).pipe(
|
||||||
|
Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const chat = yield* session.create({})
|
const chat = yield* session.create({})
|
||||||
const parent = yield* user(chat.id, "abort")
|
const parent = yield* user(chat.id, "abort")
|
||||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
const mdl = model(100)
|
||||||
const errs: string[] = []
|
const errs: string[] = []
|
||||||
const off = yield* bus.subscribeCallback(Session.Event.Error, (evt) => {
|
const off = yield* bus.subscribeCallback(Session.Event.Error, (evt) => {
|
||||||
if (evt.properties.sessionID !== chat.id) return
|
if (evt.properties.sessionID !== chat.id) return
|
||||||
@@ -661,7 +779,7 @@ it.live("session.processor effect tests record aborted errors and idle state", (
|
|||||||
})
|
})
|
||||||
.pipe(Effect.forkChild)
|
.pipe(Effect.forkChild)
|
||||||
|
|
||||||
yield* llm.wait(1)
|
yield* Effect.promise(() => ready.promise)
|
||||||
yield* Fiber.interrupt(run)
|
yield* Fiber.interrupt(run)
|
||||||
|
|
||||||
const exit = yield* Fiber.await(run)
|
const exit = yield* Fiber.await(run)
|
||||||
@@ -670,7 +788,7 @@ it.live("session.processor effect tests record aborted errors and idle state", (
|
|||||||
}
|
}
|
||||||
yield* Effect.promise(() => seen.promise)
|
yield* Effect.promise(() => seen.promise)
|
||||||
const stored = yield* Effect.promise(() => MessageV2.get({ sessionID: chat.id, messageID: msg.id }))
|
const stored = yield* Effect.promise(() => MessageV2.get({ sessionID: chat.id, messageID: msg.id }))
|
||||||
const state = yield* sts.get(chat.id)
|
const state = yield* status.get(chat.id)
|
||||||
off()
|
off()
|
||||||
|
|
||||||
expect(Exit.isFailure(exit)).toBe(true)
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
@@ -685,23 +803,30 @@ it.live("session.processor effect tests record aborted errors and idle state", (
|
|||||||
expect(state).toMatchObject({ type: "idle" })
|
expect(state).toMatchObject({ type: "idle" })
|
||||||
expect(errs).toContain("MessageAbortedError")
|
expect(errs).toContain("MessageAbortedError")
|
||||||
}),
|
}),
|
||||||
{ git: true, config: (url) => providerCfg(url) },
|
{ git: true },
|
||||||
),
|
)
|
||||||
)
|
})
|
||||||
|
|
||||||
it.live("session.processor effect tests mark interruptions aborted without manual abort", () =>
|
it.effect("session.processor effect tests mark interruptions aborted without manual abort", () => {
|
||||||
provideTmpdirServer(
|
return provideTmpdirInstance(
|
||||||
({ dir, llm }) =>
|
(dir) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { processors, session, provider } = yield* boot()
|
const ready = defer<void>()
|
||||||
const sts = yield* SessionStatus.Service
|
const processors = yield* SessionProcessor.Service
|
||||||
|
const session = yield* Session.Service
|
||||||
|
const status = yield* SessionStatus.Service
|
||||||
|
const test = yield* TestLLM
|
||||||
|
|
||||||
yield* llm.hang
|
yield* test.push((input) =>
|
||||||
|
hang(input, start()).pipe(
|
||||||
|
Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const chat = yield* session.create({})
|
const chat = yield* session.create({})
|
||||||
const parent = yield* user(chat.id, "interrupt")
|
const parent = yield* user(chat.id, "interrupt")
|
||||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
const mdl = model(100)
|
||||||
const handle = yield* processors.create({
|
const handle = yield* processors.create({
|
||||||
assistantMessage: msg,
|
assistantMessage: msg,
|
||||||
sessionID: chat.id,
|
sessionID: chat.id,
|
||||||
@@ -727,12 +852,12 @@ it.live("session.processor effect tests mark interruptions aborted without manua
|
|||||||
})
|
})
|
||||||
.pipe(Effect.forkChild)
|
.pipe(Effect.forkChild)
|
||||||
|
|
||||||
yield* llm.wait(1)
|
yield* Effect.promise(() => ready.promise)
|
||||||
yield* Fiber.interrupt(run)
|
yield* Fiber.interrupt(run)
|
||||||
|
|
||||||
const exit = yield* Fiber.await(run)
|
const exit = yield* Fiber.await(run)
|
||||||
const stored = yield* Effect.promise(() => MessageV2.get({ sessionID: chat.id, messageID: msg.id }))
|
const stored = yield* Effect.promise(() => MessageV2.get({ sessionID: chat.id, messageID: msg.id }))
|
||||||
const state = yield* sts.get(chat.id)
|
const state = yield* status.get(chat.id)
|
||||||
|
|
||||||
expect(Exit.isFailure(exit)).toBe(true)
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
expect(handle.message.error?.name).toBe("MessageAbortedError")
|
expect(handle.message.error?.name).toBe("MessageAbortedError")
|
||||||
@@ -742,6 +867,6 @@ it.live("session.processor effect tests mark interruptions aborted without manua
|
|||||||
}
|
}
|
||||||
expect(state).toMatchObject({ type: "idle" })
|
expect(state).toMatchObject({ type: "idle" })
|
||||||
}),
|
}),
|
||||||
{ git: true, config: (url) => providerCfg(url) },
|
{ git: true },
|
||||||
),
|
)
|
||||||
)
|
})
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { describe, expect, spyOn, test } from "bun:test"
|
||||||
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { Provider } from "../../src/provider/provider"
|
||||||
|
import { Session } from "../../src/session"
|
||||||
|
import { MessageV2 } from "../../src/session/message-v2"
|
||||||
|
import { SessionPrompt } from "../../src/session/prompt"
|
||||||
|
import { SessionStatus } from "../../src/session/status"
|
||||||
|
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||||
|
import { Log } from "../../src/util/log"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
Log.init({ print: false })
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: () => void
|
||||||
|
const promise = new Promise<void>((done) => {
|
||||||
|
resolve = done
|
||||||
|
})
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: seed a session with a user message + finished assistant message
|
||||||
|
// so loop() exits immediately without calling any LLM
|
||||||
|
async function seed(sessionID: SessionID) {
|
||||||
|
const userMsg: MessageV2.Info = {
|
||||||
|
id: MessageID.ascending(),
|
||||||
|
role: "user",
|
||||||
|
sessionID,
|
||||||
|
time: { created: Date.now() },
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "openai" as any, modelID: "gpt-5.2" as any },
|
||||||
|
}
|
||||||
|
await Session.updateMessage(userMsg)
|
||||||
|
await Session.updatePart({
|
||||||
|
id: PartID.ascending(),
|
||||||
|
messageID: userMsg.id,
|
||||||
|
sessionID,
|
||||||
|
type: "text",
|
||||||
|
text: "hello",
|
||||||
|
})
|
||||||
|
|
||||||
|
const assistantMsg: MessageV2.Info = {
|
||||||
|
id: MessageID.ascending(),
|
||||||
|
role: "assistant",
|
||||||
|
parentID: userMsg.id,
|
||||||
|
sessionID,
|
||||||
|
mode: "build",
|
||||||
|
agent: "build",
|
||||||
|
cost: 0,
|
||||||
|
path: { cwd: "/tmp", root: "/tmp" },
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
modelID: "gpt-5.2" as any,
|
||||||
|
providerID: "openai" as any,
|
||||||
|
time: { created: Date.now(), completed: Date.now() },
|
||||||
|
finish: "stop",
|
||||||
|
}
|
||||||
|
await Session.updateMessage(assistantMsg)
|
||||||
|
await Session.updatePart({
|
||||||
|
id: PartID.ascending(),
|
||||||
|
messageID: assistantMsg.id,
|
||||||
|
sessionID,
|
||||||
|
type: "text",
|
||||||
|
text: "hi there",
|
||||||
|
})
|
||||||
|
|
||||||
|
return { userMsg, assistantMsg }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("session.prompt concurrency", () => {
|
||||||
|
test("loop returns assistant message and sets status to idle", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const session = await Session.create({})
|
||||||
|
await seed(session.id)
|
||||||
|
|
||||||
|
const result = await SessionPrompt.loop({ sessionID: session.id })
|
||||||
|
expect(result.info.role).toBe("assistant")
|
||||||
|
if (result.info.role === "assistant") expect(result.info.finish).toBe("stop")
|
||||||
|
|
||||||
|
const status = await SessionStatus.get(session.id)
|
||||||
|
expect(status.type).toBe("idle")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("concurrent loop callers get the same result", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const session = await Session.create({})
|
||||||
|
await seed(session.id)
|
||||||
|
|
||||||
|
const [a, b] = await Promise.all([
|
||||||
|
SessionPrompt.loop({ sessionID: session.id }),
|
||||||
|
SessionPrompt.loop({ sessionID: session.id }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(a.info.id).toBe(b.info.id)
|
||||||
|
expect(a.info.role).toBe("assistant")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("assertNotBusy throws when loop is running", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const session = await Session.create({})
|
||||||
|
const userMsg: MessageV2.Info = {
|
||||||
|
id: MessageID.ascending(),
|
||||||
|
role: "user",
|
||||||
|
sessionID: session.id,
|
||||||
|
time: { created: Date.now() },
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "openai" as any, modelID: "gpt-5.2" as any },
|
||||||
|
}
|
||||||
|
await Session.updateMessage(userMsg)
|
||||||
|
await Session.updatePart({
|
||||||
|
id: PartID.ascending(),
|
||||||
|
messageID: userMsg.id,
|
||||||
|
sessionID: session.id,
|
||||||
|
type: "text",
|
||||||
|
text: "hello",
|
||||||
|
})
|
||||||
|
|
||||||
|
const ready = deferred()
|
||||||
|
const gate = deferred()
|
||||||
|
const getModel = spyOn(Provider, "getModel").mockImplementation(async () => {
|
||||||
|
ready.resolve()
|
||||||
|
await gate.promise
|
||||||
|
throw new Error("test stop")
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loopPromise = SessionPrompt.loop({ sessionID: session.id }).catch(() => undefined)
|
||||||
|
await ready.promise
|
||||||
|
|
||||||
|
await expect(SessionPrompt.assertNotBusy(session.id)).rejects.toBeInstanceOf(Session.BusyError)
|
||||||
|
|
||||||
|
gate.resolve()
|
||||||
|
await loopPromise
|
||||||
|
} finally {
|
||||||
|
gate.resolve()
|
||||||
|
getModel.mockRestore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// After loop completes, assertNotBusy should succeed
|
||||||
|
await SessionPrompt.assertNotBusy(session.id)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cancel sets status to idle", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const session = await Session.create({})
|
||||||
|
// Seed only a user message — loop must call getModel to proceed
|
||||||
|
const userMsg: MessageV2.Info = {
|
||||||
|
id: MessageID.ascending(),
|
||||||
|
role: "user",
|
||||||
|
sessionID: session.id,
|
||||||
|
time: { created: Date.now() },
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "openai" as any, modelID: "gpt-5.2" as any },
|
||||||
|
}
|
||||||
|
await Session.updateMessage(userMsg)
|
||||||
|
await Session.updatePart({
|
||||||
|
id: PartID.ascending(),
|
||||||
|
messageID: userMsg.id,
|
||||||
|
sessionID: session.id,
|
||||||
|
type: "text",
|
||||||
|
text: "hello",
|
||||||
|
})
|
||||||
|
// Also seed an assistant message so lastAssistant() fallback can find it
|
||||||
|
const assistantMsg: MessageV2.Info = {
|
||||||
|
id: MessageID.ascending(),
|
||||||
|
role: "assistant",
|
||||||
|
parentID: userMsg.id,
|
||||||
|
sessionID: session.id,
|
||||||
|
mode: "build",
|
||||||
|
agent: "build",
|
||||||
|
cost: 0,
|
||||||
|
path: { cwd: "/tmp", root: "/tmp" },
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
modelID: "gpt-5.2" as any,
|
||||||
|
providerID: "openai" as any,
|
||||||
|
time: { created: Date.now() },
|
||||||
|
}
|
||||||
|
await Session.updateMessage(assistantMsg)
|
||||||
|
await Session.updatePart({
|
||||||
|
id: PartID.ascending(),
|
||||||
|
messageID: assistantMsg.id,
|
||||||
|
sessionID: session.id,
|
||||||
|
type: "text",
|
||||||
|
text: "hi there",
|
||||||
|
})
|
||||||
|
|
||||||
|
const ready = deferred()
|
||||||
|
const gate = deferred()
|
||||||
|
const getModel = spyOn(Provider, "getModel").mockImplementation(async () => {
|
||||||
|
ready.resolve()
|
||||||
|
await gate.promise
|
||||||
|
throw new Error("test stop")
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Start loop — it will block in getModel (assistant has no finish, so loop continues)
|
||||||
|
const loopPromise = SessionPrompt.loop({ sessionID: session.id })
|
||||||
|
|
||||||
|
await ready.promise
|
||||||
|
|
||||||
|
await SessionPrompt.cancel(session.id)
|
||||||
|
|
||||||
|
const status = await SessionStatus.get(session.id)
|
||||||
|
expect(status.type).toBe("idle")
|
||||||
|
|
||||||
|
// loop should resolve cleanly, not throw "All fibers interrupted"
|
||||||
|
const result = await loopPromise
|
||||||
|
expect(result.info.role).toBe("assistant")
|
||||||
|
expect(result.info.id).toBe(assistantMsg.id)
|
||||||
|
} finally {
|
||||||
|
gate.resolve()
|
||||||
|
getModel.mockRestore()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, 10000)
|
||||||
|
|
||||||
|
test("cancel on idle session just sets idle", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const session = await Session.create({})
|
||||||
|
await SessionPrompt.cancel(session.id)
|
||||||
|
const status = await SessionStatus.get(session.id)
|
||||||
|
expect(status.type).toBe("idle")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -10,66 +10,9 @@ import { Instance } from "../../src/project/instance"
|
|||||||
import { MessageID, PartID } from "../../src/session/schema"
|
import { MessageID, PartID } from "../../src/session/schema"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
const projectRoot = path.join(__dirname, "../..")
|
||||||
Log.init({ print: false })
|
Log.init({ print: false })
|
||||||
|
|
||||||
function user(sessionID: string, agent = "default") {
|
|
||||||
return Session.updateMessage({
|
|
||||||
id: MessageID.ascending(),
|
|
||||||
role: "user" as const,
|
|
||||||
sessionID: sessionID as any,
|
|
||||||
agent,
|
|
||||||
model: { providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-4") },
|
|
||||||
time: { created: Date.now() },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function assistant(sessionID: string, parentID: string, dir: string) {
|
|
||||||
return Session.updateMessage({
|
|
||||||
id: MessageID.ascending(),
|
|
||||||
role: "assistant" as const,
|
|
||||||
sessionID: sessionID as any,
|
|
||||||
mode: "default",
|
|
||||||
agent: "default",
|
|
||||||
path: { cwd: dir, root: dir },
|
|
||||||
cost: 0,
|
|
||||||
tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
||||||
modelID: ModelID.make("gpt-4"),
|
|
||||||
providerID: ProviderID.make("openai"),
|
|
||||||
parentID: parentID as any,
|
|
||||||
time: { created: Date.now() },
|
|
||||||
finish: "end_turn",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function text(sessionID: string, messageID: string, content: string) {
|
|
||||||
return Session.updatePart({
|
|
||||||
id: PartID.ascending(),
|
|
||||||
messageID: messageID as any,
|
|
||||||
sessionID: sessionID as any,
|
|
||||||
type: "text" as const,
|
|
||||||
text: content,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function tool(sessionID: string, messageID: string) {
|
|
||||||
return Session.updatePart({
|
|
||||||
id: PartID.ascending(),
|
|
||||||
messageID: messageID as any,
|
|
||||||
sessionID: sessionID as any,
|
|
||||||
type: "tool" as const,
|
|
||||||
tool: "bash",
|
|
||||||
callID: "call-1",
|
|
||||||
state: {
|
|
||||||
status: "completed" as const,
|
|
||||||
input: {},
|
|
||||||
output: "done",
|
|
||||||
title: "",
|
|
||||||
metadata: {},
|
|
||||||
time: { start: 0, end: 1 },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("revert + compact workflow", () => {
|
describe("revert + compact workflow", () => {
|
||||||
test("should properly handle compact command after revert", async () => {
|
test("should properly handle compact command after revert", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
@@ -340,98 +283,4 @@ describe("revert + compact workflow", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("cleanup with partID removes parts from the revert point onward", async () => {
|
|
||||||
await using tmp = await tmpdir({ git: true })
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const session = await Session.create({})
|
|
||||||
const sid = session.id
|
|
||||||
|
|
||||||
const u1 = await user(sid)
|
|
||||||
const p1 = await text(sid, u1.id, "first part")
|
|
||||||
const p2 = await tool(sid, u1.id)
|
|
||||||
const p3 = await text(sid, u1.id, "third part")
|
|
||||||
|
|
||||||
// Set revert state pointing at a specific part
|
|
||||||
await Session.setRevert({
|
|
||||||
sessionID: sid,
|
|
||||||
revert: { messageID: u1.id, partID: p2.id },
|
|
||||||
summary: { additions: 0, deletions: 0, files: 0 },
|
|
||||||
})
|
|
||||||
|
|
||||||
const info = await Session.get(sid)
|
|
||||||
await SessionRevert.cleanup(info)
|
|
||||||
|
|
||||||
const msgs = await Session.messages({ sessionID: sid })
|
|
||||||
expect(msgs.length).toBe(1)
|
|
||||||
// Only the first part should remain (before the revert partID)
|
|
||||||
expect(msgs[0].parts.length).toBe(1)
|
|
||||||
expect(msgs[0].parts[0].id).toBe(p1.id)
|
|
||||||
|
|
||||||
const cleared = await Session.get(sid)
|
|
||||||
expect(cleared.revert).toBeUndefined()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("cleanup removes messages after revert point but keeps earlier ones", async () => {
|
|
||||||
await using tmp = await tmpdir({ git: true })
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const session = await Session.create({})
|
|
||||||
const sid = session.id
|
|
||||||
|
|
||||||
const u1 = await user(sid)
|
|
||||||
await text(sid, u1.id, "hello")
|
|
||||||
const a1 = await assistant(sid, u1.id, tmp.path)
|
|
||||||
await text(sid, a1.id, "hi back")
|
|
||||||
|
|
||||||
const u2 = await user(sid)
|
|
||||||
await text(sid, u2.id, "second question")
|
|
||||||
const a2 = await assistant(sid, u2.id, tmp.path)
|
|
||||||
await text(sid, a2.id, "second answer")
|
|
||||||
|
|
||||||
// Revert from u2 onward
|
|
||||||
await Session.setRevert({
|
|
||||||
sessionID: sid,
|
|
||||||
revert: { messageID: u2.id },
|
|
||||||
summary: { additions: 0, deletions: 0, files: 0 },
|
|
||||||
})
|
|
||||||
|
|
||||||
const info = await Session.get(sid)
|
|
||||||
await SessionRevert.cleanup(info)
|
|
||||||
|
|
||||||
const msgs = await Session.messages({ sessionID: sid })
|
|
||||||
const ids = msgs.map((m) => m.info.id)
|
|
||||||
expect(ids).toContain(u1.id)
|
|
||||||
expect(ids).toContain(a1.id)
|
|
||||||
expect(ids).not.toContain(u2.id)
|
|
||||||
expect(ids).not.toContain(a2.id)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("cleanup is a no-op when session has no revert state", async () => {
|
|
||||||
await using tmp = await tmpdir({ git: true })
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const session = await Session.create({})
|
|
||||||
const sid = session.id
|
|
||||||
|
|
||||||
const u1 = await user(sid)
|
|
||||||
await text(sid, u1.id, "hello")
|
|
||||||
|
|
||||||
const info = await Session.get(sid)
|
|
||||||
expect(info.revert).toBeUndefined()
|
|
||||||
await SessionRevert.cleanup(info)
|
|
||||||
|
|
||||||
const msgs = await Session.messages({ sessionID: sid })
|
|
||||||
expect(msgs.length).toBe(1)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ describe("Truncate", () => {
|
|||||||
const DAY_MS = 24 * 60 * 60 * 1000
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
const it = testEffect(Layer.mergeAll(TruncateSvc.defaultLayer, NodeFileSystem.layer))
|
const it = testEffect(Layer.mergeAll(TruncateSvc.defaultLayer, NodeFileSystem.layer))
|
||||||
|
|
||||||
it.live("deletes files older than 7 days and preserves recent files", () =>
|
it.effect("deletes files older than 7 days and preserves recent files", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -21,8 +21,8 @@
|
|||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentui/core": ">=0.1.95",
|
"@opentui/core": ">=0.1.93",
|
||||||
"@opentui/solid": ">=0.1.95"
|
"@opentui/solid": ">=0.1.93"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@opentui/core": {
|
"@opentui/core": {
|
||||||
@@ -33,8 +33,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@opentui/core": "0.1.95",
|
"@opentui/core": "0.1.93",
|
||||||
"@opentui/solid": "0.1.95",
|
"@opentui/solid": "0.1.93",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -4,6 +4,20 @@ export type ClientOptions = {
|
|||||||
baseUrl: `${string}://${string}` | (string & {})
|
baseUrl: `${string}://${string}` | (string & {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EventInstallationUpdated = {
|
||||||
|
type: "installation.updated"
|
||||||
|
properties: {
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventInstallationUpdateAvailable = {
|
||||||
|
type: "installation.update-available"
|
||||||
|
properties: {
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type Project = {
|
export type Project = {
|
||||||
id: string
|
id: string
|
||||||
worktree: string
|
worktree: string
|
||||||
@@ -33,20 +47,6 @@ export type EventProjectUpdated = {
|
|||||||
properties: Project
|
properties: Project
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EventInstallationUpdated = {
|
|
||||||
type: "installation.updated"
|
|
||||||
properties: {
|
|
||||||
version: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventInstallationUpdateAvailable = {
|
|
||||||
type: "installation.update-available"
|
|
||||||
properties: {
|
|
||||||
version: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventServerInstanceDisposed = {
|
export type EventServerInstanceDisposed = {
|
||||||
type: "server.instance.disposed"
|
type: "server.instance.disposed"
|
||||||
properties: {
|
properties: {
|
||||||
@@ -964,9 +964,9 @@ export type EventSessionDeleted = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type Event =
|
export type Event =
|
||||||
| EventProjectUpdated
|
|
||||||
| EventInstallationUpdated
|
| EventInstallationUpdated
|
||||||
| EventInstallationUpdateAvailable
|
| EventInstallationUpdateAvailable
|
||||||
|
| EventProjectUpdated
|
||||||
| EventServerInstanceDisposed
|
| EventServerInstanceDisposed
|
||||||
| EventServerConnected
|
| EventServerConnected
|
||||||
| EventGlobalDisposed
|
| EventGlobalDisposed
|
||||||
|
|||||||
+41
-41
@@ -7040,6 +7040,44 @@
|
|||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
|
"Event.installation.updated": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "installation.updated"
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["version"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["type", "properties"]
|
||||||
|
},
|
||||||
|
"Event.installation.update-available": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "installation.update-available"
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["version"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["type", "properties"]
|
||||||
|
},
|
||||||
"Project": {
|
"Project": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -7116,44 +7154,6 @@
|
|||||||
},
|
},
|
||||||
"required": ["type", "properties"]
|
"required": ["type", "properties"]
|
||||||
},
|
},
|
||||||
"Event.installation.updated": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"const": "installation.updated"
|
|
||||||
},
|
|
||||||
"properties": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"version": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["version"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["type", "properties"]
|
|
||||||
},
|
|
||||||
"Event.installation.update-available": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"const": "installation.update-available"
|
|
||||||
},
|
|
||||||
"properties": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"version": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["version"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["type", "properties"]
|
|
||||||
},
|
|
||||||
"Event.server.instance.disposed": {
|
"Event.server.instance.disposed": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -9733,15 +9733,15 @@
|
|||||||
},
|
},
|
||||||
"Event": {
|
"Event": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Event.project.updated"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/Event.installation.updated"
|
"$ref": "#/components/schemas/Event.installation.updated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/Event.installation.update-available"
|
"$ref": "#/components/schemas/Event.installation.update-available"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/Event.project.updated"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/Event.server.instance.disposed"
|
"$ref": "#/components/schemas/Event.server.instance.disposed"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.3.13",
|
"version": "1.3.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -7,12 +7,11 @@ export function DockPrompt(props: {
|
|||||||
children: JSX.Element
|
children: JSX.Element
|
||||||
footer: JSX.Element
|
footer: JSX.Element
|
||||||
ref?: (el: HTMLDivElement) => void
|
ref?: (el: HTMLDivElement) => void
|
||||||
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
|
|
||||||
}) {
|
}) {
|
||||||
const slot = (name: string) => `${props.kind}-${name}`
|
const slot = (name: string) => `${props.kind}-${name}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-component="dock-prompt" data-kind={props.kind} ref={props.ref} onKeyDown={props.onKeyDown}>
|
<div data-component="dock-prompt" data-kind={props.kind} ref={props.ref}>
|
||||||
<DockShell data-slot={slot("body")}>
|
<DockShell data-slot={slot("body")}>
|
||||||
<div data-slot={slot("header")}>{props.header}</div>
|
<div data-slot={slot("header")}>{props.header}</div>
|
||||||
<div data-slot={slot("content")}>{props.children}</div>
|
<div data-slot={slot("content")}>{props.children}</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { render as renderSolid } from "solid-js/web"
|
|||||||
import { useI18n } from "../context/i18n"
|
import { useI18n } from "../context/i18n"
|
||||||
import { createHoverCommentUtility } from "../pierre/comment-hover"
|
import { createHoverCommentUtility } from "../pierre/comment-hover"
|
||||||
import { cloneSelectedLineRange, formatSelectedLineLabel, lineInSelectedRange } from "../pierre/selection-bridge"
|
import { cloneSelectedLineRange, formatSelectedLineLabel, lineInSelectedRange } from "../pierre/selection-bridge"
|
||||||
import { LineComment, LineCommentEditor, type LineCommentEditorProps } from "./line-comment"
|
import { LineComment, LineCommentEditor } from "./line-comment"
|
||||||
|
|
||||||
export type LineCommentAnnotationMeta<T> =
|
export type LineCommentAnnotationMeta<T> =
|
||||||
| { kind: "comment"; key: string; comment: T }
|
| { kind: "comment"; key: string; comment: T }
|
||||||
@@ -55,7 +55,6 @@ type LineCommentControllerProps<T extends LineCommentShape> = {
|
|||||||
comments: Accessor<T[]>
|
comments: Accessor<T[]>
|
||||||
draftKey: Accessor<string>
|
draftKey: Accessor<string>
|
||||||
label: string
|
label: string
|
||||||
mention?: LineCommentEditorProps["mention"]
|
|
||||||
state: LineCommentStateProps<string>
|
state: LineCommentStateProps<string>
|
||||||
onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void
|
onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void
|
||||||
onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void
|
onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void
|
||||||
@@ -86,7 +85,6 @@ type CommentProps = {
|
|||||||
type DraftProps = {
|
type DraftProps = {
|
||||||
value: string
|
value: string
|
||||||
selection: JSX.Element
|
selection: JSX.Element
|
||||||
mention?: LineCommentEditorProps["mention"]
|
|
||||||
onInput: (value: string) => void
|
onInput: (value: string) => void
|
||||||
onCancel: VoidFunction
|
onCancel: VoidFunction
|
||||||
onSubmit: (value: string) => void
|
onSubmit: (value: string) => void
|
||||||
@@ -150,7 +148,6 @@ export function createLineCommentAnnotationRenderer<T>(props: {
|
|||||||
onPopoverFocusOut={view().editor!.onPopoverFocusOut}
|
onPopoverFocusOut={view().editor!.onPopoverFocusOut}
|
||||||
cancelLabel={view().editor!.cancelLabel}
|
cancelLabel={view().editor!.cancelLabel}
|
||||||
submitLabel={view().editor!.submitLabel}
|
submitLabel={view().editor!.submitLabel}
|
||||||
mention={view().editor!.mention}
|
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
@@ -170,7 +167,6 @@ export function createLineCommentAnnotationRenderer<T>(props: {
|
|||||||
onCancel={view().onCancel}
|
onCancel={view().onCancel}
|
||||||
onSubmit={view().onSubmit}
|
onSubmit={view().onSubmit}
|
||||||
onPopoverFocusOut={view().onPopoverFocusOut}
|
onPopoverFocusOut={view().onPopoverFocusOut}
|
||||||
mention={view().mention}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}, host)
|
}, host)
|
||||||
@@ -393,7 +389,6 @@ export function createLineCommentController<T extends LineCommentShape>(
|
|||||||
return note.draft()
|
return note.draft()
|
||||||
},
|
},
|
||||||
selection: formatSelectedLineLabel(comment.selection, i18n.t),
|
selection: formatSelectedLineLabel(comment.selection, i18n.t),
|
||||||
mention: props.mention,
|
|
||||||
onInput: note.setDraft,
|
onInput: note.setDraft,
|
||||||
onCancel: note.cancelDraft,
|
onCancel: note.cancelDraft,
|
||||||
onSubmit: (value: string) => {
|
onSubmit: (value: string) => {
|
||||||
@@ -420,7 +415,6 @@ export function createLineCommentController<T extends LineCommentShape>(
|
|||||||
return note.draft()
|
return note.draft()
|
||||||
},
|
},
|
||||||
selection: formatSelectedLineLabel(range, i18n.t),
|
selection: formatSelectedLineLabel(range, i18n.t),
|
||||||
mention: props.mention,
|
|
||||||
onInput: note.setDraft,
|
onInput: note.setDraft,
|
||||||
onCancel: note.cancelDraft,
|
onCancel: note.cancelDraft,
|
||||||
onSubmit: (comment) => {
|
onSubmit: (comment) => {
|
||||||
|
|||||||
@@ -178,58 +178,6 @@ export const lineCommentStyles = `
|
|||||||
box-shadow: var(--shadow-xs-border-select);
|
box-shadow: var(--shadow-xs-border-select);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="line-comment"] [data-slot="line-comment-mention-list"] {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
max-height: 180px;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 4px;
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--surface-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="line-comment"] [data-slot="line-comment-mention-item"] {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 6px 8px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-strong);
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="line-comment"] [data-slot="line-comment-mention-item"][data-active] {
|
|
||||||
background: var(--surface-raised-base-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="line-comment"] [data-slot="line-comment-mention-path"] {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
min-width: 0;
|
|
||||||
font-family: var(--font-family-sans);
|
|
||||||
font-size: var(--font-size-small);
|
|
||||||
line-height: var(--line-height-large);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="line-comment"] [data-slot="line-comment-mention-dir"] {
|
|
||||||
min-width: 0;
|
|
||||||
color: var(--text-weak);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="line-comment"] [data-slot="line-comment-mention-file"] {
|
|
||||||
color: var(--text-strong);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="line-comment"] [data-slot="line-comment-actions"] {
|
[data-component="line-comment"] [data-slot="line-comment-actions"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user