Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12ac69ee91 | ||
|
|
9a19e84265 | ||
|
|
338666d13e | ||
|
|
7b8a1037a0 | ||
|
|
ee5cf45ef9 | ||
|
|
2e1593dea5 | ||
|
|
0f3d168fdd | ||
|
|
ce09fc8356 | ||
|
|
44a35c5895 | ||
|
|
8a321c4536 | ||
|
|
ef9e567e5f | ||
|
|
b396b71c6f | ||
|
|
d8efc575fa | ||
|
|
e1fbed8fb6 | ||
|
|
12ae22378f | ||
|
|
a88b436eec | ||
|
|
dbe36851bc | ||
|
|
ff9d7cab5c | ||
|
|
88681d389b | ||
|
|
ae2ecd1ed3 | ||
|
|
159d271e1e | ||
|
|
762850dfe5 | ||
|
|
b039702e8c | ||
|
|
094886c848 | ||
|
|
dc0297f4ac | ||
|
|
3ab67f3280 | ||
|
|
a2e6bd503b | ||
|
|
05ac345696 | ||
|
|
e3feca09b3 | ||
|
|
896ad7b884 | ||
|
|
0a945219a9 | ||
|
|
94828eb44b | ||
|
|
96192495ae | ||
|
|
f7b5576bcc | ||
|
|
cb35493242 | ||
|
|
5bfd7fd16c | ||
|
|
2932b41e64 | ||
|
|
eb389c58eb | ||
|
|
54ff0a669b | ||
|
|
e56999fd36 | ||
|
|
1124315267 | ||
|
|
2bf3f3041f | ||
|
|
6e4db5666a | ||
|
|
564cde393e | ||
|
|
c813927bb6 | ||
|
|
5970c12d90 | ||
|
|
116a4e33ba |
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"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",
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"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",
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"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",
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -253,7 +253,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -307,7 +307,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -337,7 +337,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -353,7 +353,7 @@
|
|||||||
},
|
},
|
||||||
"packages/http-recorder": {
|
"packages/http-recorder": {
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -366,7 +366,7 @@
|
|||||||
},
|
},
|
||||||
"packages/llm": {
|
"packages/llm": {
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@smithy/eventstream-codec": "4.2.14",
|
"@smithy/eventstream-codec": "4.2.14",
|
||||||
"@smithy/util-utf8": "4.2.2",
|
"@smithy/util-utf8": "4.2.2",
|
||||||
@@ -384,7 +384,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -421,6 +421,7 @@
|
|||||||
"@octokit/graphql": "9.0.2",
|
"@octokit/graphql": "9.0.2",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
"@openauthjs/openauth": "catalog:",
|
"@openauthjs/openauth": "catalog:",
|
||||||
|
"@opencode-ai/llm": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -489,6 +490,7 @@
|
|||||||
"@babel/core": "7.28.4",
|
"@babel/core": "7.28.4",
|
||||||
"@octokit/webhooks-types": "7.6.1",
|
"@octokit/webhooks-types": "7.6.1",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
|
"@opencode-ai/http-recorder": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||||
@@ -520,7 +522,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -536,9 +538,9 @@
|
|||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentui/core": ">=0.2.13",
|
"@opentui/core": ">=0.2.14",
|
||||||
"@opentui/keymap": ">=0.2.13",
|
"@opentui/keymap": ">=0.2.14",
|
||||||
"@opentui/solid": ">=0.2.13",
|
"@opentui/solid": ">=0.2.14",
|
||||||
},
|
},
|
||||||
"optionalPeers": [
|
"optionalPeers": [
|
||||||
"@opentui/core",
|
"@opentui/core",
|
||||||
@@ -558,7 +560,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -573,7 +575,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -608,7 +610,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -657,7 +659,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -721,9 +723,9 @@
|
|||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@opentui/core": "0.2.13",
|
"@opentui/core": "0.2.14",
|
||||||
"@opentui/keymap": "0.2.13",
|
"@opentui/keymap": "0.2.14",
|
||||||
"@opentui/solid": "0.2.13",
|
"@opentui/solid": "0.2.14",
|
||||||
"@pierre/diffs": "1.1.0-beta.18",
|
"@pierre/diffs": "1.1.0-beta.18",
|
||||||
"@playwright/test": "1.59.1",
|
"@playwright/test": "1.59.1",
|
||||||
"@sentry/solid": "10.36.0",
|
"@sentry/solid": "10.36.0",
|
||||||
@@ -1590,23 +1592,23 @@
|
|||||||
|
|
||||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
||||||
|
|
||||||
"@opentui/core": ["@opentui/core@0.2.13", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.13", "@opentui/core-darwin-x64": "0.2.13", "@opentui/core-linux-arm64": "0.2.13", "@opentui/core-linux-x64": "0.2.13", "@opentui/core-win32-arm64": "0.2.13", "@opentui/core-win32-x64": "0.2.13" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-CFnke/uhuekinVIkcyeVF62VC35I4OTrw5MXUlKU18mMsjb9U1pzB0oBJp3us1oCHKd/KuaeCnsRz4zhEPThKA=="],
|
"@opentui/core": ["@opentui/core@0.2.14", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.14", "@opentui/core-darwin-x64": "0.2.14", "@opentui/core-linux-arm64": "0.2.14", "@opentui/core-linux-x64": "0.2.14", "@opentui/core-win32-arm64": "0.2.14", "@opentui/core-win32-x64": "0.2.14" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-17YCr3BqM9mhi/DdNVM+omgmrKQNIl0G5RzoaTFOHe4+OAhG+W3iooYi+WdsekJWSUOEwZqDRz0QBTZhOtgZsQ=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZREOhS54UkF2nd7keORI1NwFe2xQdX6NCA2Uft945NqsZ5+cBe4dqoPcn6Qe4WcSfysaZQBcN0eKo623v+hiNA=="],
|
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iS4NZQkOKX2EP5rsNjDcU7inDLcKhPaSBn8ENjDXKx2smOh7p/rgM2qlEaiLI3njtL784QoF+nxTzSXbEI6+Jw=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-4q+sjMATKbx/YzEKjuB4LaYe9+vrK0jFzuHaKY/Xg/cLXD8yZ9OpnyHQMCs75ijTBNKZwrsRhCUV174KLsb4eQ=="],
|
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-ft4ZwYHCV0VtRMwQtHH5mAgwqRLHEXP26DWcwtCZWDEHDvghClBR0cj9UZLH5JAKn/j7ds5hZDCCZz+nUiEHYA=="],
|
||||||
|
|
||||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-yPQuEdSLmZFvml4B4KevbcWqWFnSJ5xQPBTUX0Y9lEGgw8xEMkJH7QBPgGSDElTihYPu8/jTZKR0pKxknxYdbw=="],
|
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-t/EKD4+rlzWuwYAa6NzGCmiBOHvF+hzjNwExj+dnSqX5wK7TU+VHl+N2iYUl4VhhJK94kPP6BnrF5GcHnZGFLg=="],
|
||||||
|
|
||||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.13", "", { "os": "linux", "cpu": "x64" }, "sha512-8i1dR80/3mz5dOGja8+ui7SHl0FkaPF60YtKJhTYEOrvkhGpQoFVAx0YhjO3YM5cYQ4CkZyeD1+bpK4v65YSyQ=="],
|
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-Lvqmd92UZ+KZVnr0xU0jYj4XqnCSsBQJHS/FpYkJgZSAN7/4NmPlgMvQXIGW8a3BcFaGRKe8LuGpqM2E4oaX0Q=="],
|
||||||
|
|
||||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-5tlUt/sV/fiELecwOqeIIQOgjtdyUmNTe828JGhwRdMWCKcKz/YPMo0gwXx/HFa3lNLThA9cKADdVMnCK6N0Ow=="],
|
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-Jnuud29daaEoZNEp80dxUDLyUcwLr+g6SruHPyyWerOe7J10JE1ihJNkDlXLT7T49xdBGYRQlRkuNGwRfZWx5A=="],
|
||||||
|
|
||||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.13", "", { "os": "win32", "cpu": "x64" }, "sha512-okqYNxKeNeEr/4IngR+lBrOYjt/cIybLh5AjPzafyNELBTJj6yAFTRSRJxMzuEjRyY3CB3a/aBveEdZ3N8YkcA=="],
|
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.14", "", { "os": "win32", "cpu": "x64" }, "sha512-2ZUNh7yaAMUwAOK8oFEO28qXqPFrWPGGD0KHK1Gp97Th9XuVZniMLtbkbrFtDRh15+PVj7MyrG0N967W7rLr1A=="],
|
||||||
|
|
||||||
"@opentui/keymap": ["@opentui/keymap@0.2.13", "", { "dependencies": { "@opentui/core": "0.2.13" }, "peerDependencies": { "@opentui/react": "0.2.13", "@opentui/solid": "0.2.13", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-Y/IVToeiBCm5KEkt8mGP9ZdIuW9NTavOdkxo5r1/lAbo9E4O9aELD97+nRh4BccRfvRALHcVcOoYTYEGCyKHHQ=="],
|
"@opentui/keymap": ["@opentui/keymap@0.2.14", "", { "dependencies": { "@opentui/core": "0.2.14" }, "peerDependencies": { "@opentui/react": "0.2.14", "@opentui/solid": "0.2.14", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-Jd4F3S98D8bJcr41jk7KcsFwwQTA0GCNKb+LoDMkPwv00k+NV6XcrXPB3QlNeM/JrVbe55pyGaT/ynZklGYHRw=="],
|
||||||
|
|
||||||
"@opentui/solid": ["@opentui/solid@0.2.13", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.13", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-NQSuXj0e2Epae0z5nT7HZp7jpHTKgYAl7mwsMM/HJ/6BOtDFago2QaWIoi70gI+SsvO5z2YUREHUaQ7BI/rH3g=="],
|
"@opentui/solid": ["@opentui/solid@0.2.14", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.14", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-tSWiiwdh/J+crkjwHgd26HXAlJHYocwkN/eUoqSH3+Y/uO3c3qRnbzQz7zzds+j4XDQU/8e9QcZshYLvQT9c7w=="],
|
||||||
|
|
||||||
"@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=="],
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-LN6VLX8bXADSPGt67+YjP1Sy0QdQY+HFPq8iXN5nkck=",
|
"x86_64-linux": "sha256-FI1mX42vJuYdUDdWevlfHz+OcYkDn/I/HUbHE/jdQvs=",
|
||||||
"aarch64-linux": "sha256-3yhpjuCLwi7KCZZvsfabtc50hgznVfD8rcMZ9/JAnSs=",
|
"aarch64-linux": "sha256-3CQzzKnh/4Zf5vyn56yR5P3ULsW7K7Fr8/RQpekEJDk=",
|
||||||
"aarch64-darwin": "sha256-6spcWVHVBuM0SlWYTFrDvForGbtxFXxeVvJMC9thN+g=",
|
"aarch64-darwin": "sha256-XPDVHMxlPpXlf43BRqNnwF809unk6iE8tvd0o92d0/w=",
|
||||||
"x86_64-darwin": "sha256-hnD68Dexq/3EP/Sm3MnL88ezIvLLa6/nVzaV+bRpn9w="
|
"x86_64-darwin": "sha256-dFXTi13RSgL62lMsep1EoE/KSEPF7Oh31PVdxW1tkzg="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -35,9 +35,9 @@
|
|||||||
"@types/cross-spawn": "6.0.6",
|
"@types/cross-spawn": "6.0.6",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@opentui/core": "0.2.13",
|
"@opentui/core": "0.2.14",
|
||||||
"@opentui/keymap": "0.2.13",
|
"@opentui/keymap": "0.2.14",
|
||||||
"@opentui/solid": "0.2.13",
|
"@opentui/solid": "0.2.14",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@types/luxon": "3.7.1",
|
"@types/luxon": "3.7.1",
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
const words = [
|
||||||
|
"alpha",
|
||||||
|
"bravo",
|
||||||
|
"charlie",
|
||||||
|
"delta",
|
||||||
|
"echo",
|
||||||
|
"foxtrot",
|
||||||
|
"golf",
|
||||||
|
"hotel",
|
||||||
|
"india",
|
||||||
|
"juliet",
|
||||||
|
"kilo",
|
||||||
|
"lima",
|
||||||
|
"metro",
|
||||||
|
"nova",
|
||||||
|
"orbit",
|
||||||
|
"pixel",
|
||||||
|
"quartz",
|
||||||
|
"river",
|
||||||
|
"signal",
|
||||||
|
"vector",
|
||||||
|
]
|
||||||
|
|
||||||
|
const sourceID = "ses_smoke_source"
|
||||||
|
const targetID = "ses_smoke_target"
|
||||||
|
const directory = "C:/OpenCode/SmokeProject"
|
||||||
|
const projectID = "proj_smoke_timeline"
|
||||||
|
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||||
|
|
||||||
|
type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
||||||
|
type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
|
||||||
|
type Message = { info: MessageInfo; parts: MessagePart[] }
|
||||||
|
|
||||||
|
function lorem(seed: number, length: number) {
|
||||||
|
let out = ""
|
||||||
|
let i = seed
|
||||||
|
while (out.length < length) {
|
||||||
|
const word = words[i % words.length]
|
||||||
|
out += (out ? " " : "") + word
|
||||||
|
if (i % 17 === 0) out += ".\n\n"
|
||||||
|
i += 7
|
||||||
|
}
|
||||||
|
return out.slice(0, length)
|
||||||
|
}
|
||||||
|
|
||||||
|
function id(prefix: string, value: number) {
|
||||||
|
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
|
||||||
|
const messageID = id("msg_user", index)
|
||||||
|
return {
|
||||||
|
info: {
|
||||||
|
id: messageID,
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1700000000000 + index * 10_000 },
|
||||||
|
summary: { diffs },
|
||||||
|
agent: "build",
|
||||||
|
model,
|
||||||
|
},
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
id: id("prt_user_text", index),
|
||||||
|
sessionID,
|
||||||
|
messageID,
|
||||||
|
type: "text",
|
||||||
|
text: lorem(index, textLength),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
|
||||||
|
const messageID = id("msg_assistant", index)
|
||||||
|
return {
|
||||||
|
info: {
|
||||||
|
id: messageID,
|
||||||
|
sessionID,
|
||||||
|
role: "assistant",
|
||||||
|
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
||||||
|
parentID,
|
||||||
|
modelID: model.modelID,
|
||||||
|
providerID: model.providerID,
|
||||||
|
mode: "build",
|
||||||
|
agent: "build",
|
||||||
|
path: { cwd: directory, root: directory },
|
||||||
|
cost: 0.01,
|
||||||
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
variant: "max",
|
||||||
|
finish: "stop",
|
||||||
|
},
|
||||||
|
parts: parts.map((part) => ({
|
||||||
|
...part,
|
||||||
|
sessionID,
|
||||||
|
messageID,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function textPart(index: number, partIndex: number, length: number): MessagePart {
|
||||||
|
return { id: id(`prt_text_${partIndex}`, index), type: "text", text: lorem(index * 13 + partIndex, length) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasoningPart(index: number, partIndex: number, length: number): MessagePart {
|
||||||
|
return {
|
||||||
|
id: id(`prt_reasoning_${partIndex}`, index),
|
||||||
|
type: "reasoning",
|
||||||
|
text: lorem(index * 19 + partIndex, length),
|
||||||
|
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 500 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolPart(
|
||||||
|
index: number,
|
||||||
|
partIndex: number,
|
||||||
|
tool: string,
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
outputLength = 160,
|
||||||
|
): MessagePart {
|
||||||
|
const metadata =
|
||||||
|
tool === "apply_patch"
|
||||||
|
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
||||||
|
: tool === "edit" || tool === "write"
|
||||||
|
? {
|
||||||
|
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
|
||||||
|
diff: patch(index, outputLength),
|
||||||
|
preview: patch(index + 1, 420),
|
||||||
|
}
|
||||||
|
: tool === "question"
|
||||||
|
? { answers: [["Proceed"], ["Keep sample output"]] }
|
||||||
|
: {}
|
||||||
|
return {
|
||||||
|
id: id(`prt_tool_${tool}_${partIndex}`, index),
|
||||||
|
type: "tool",
|
||||||
|
callID: id("call", index * 10 + partIndex),
|
||||||
|
tool,
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input,
|
||||||
|
output: lorem(index * 23 + partIndex, outputLength),
|
||||||
|
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
|
||||||
|
metadata,
|
||||||
|
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchFile(seed: number, type: "add" | "update" | "delete") {
|
||||||
|
return {
|
||||||
|
filePath: `src/generated/patch-${seed}.ts`,
|
||||||
|
relativePath: `src/generated/patch-${seed}.ts`,
|
||||||
|
type,
|
||||||
|
additions: (seed % 7) + 1,
|
||||||
|
deletions: type === "add" ? 0 : seed % 4,
|
||||||
|
patch: patch(seed, 520),
|
||||||
|
before: type === "add" ? undefined : code(seed, 18),
|
||||||
|
after: type === "delete" ? undefined : code(seed + 1, 24),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileDiff(file: string, seed: number) {
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
additions: (seed % 9) + 1,
|
||||||
|
deletions: seed % 4,
|
||||||
|
before: code(seed, 32),
|
||||||
|
after: code(seed + 1, 38),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function patch(seed: number, length: number) {
|
||||||
|
return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function code(seed: number, lines: number) {
|
||||||
|
return Array.from({ length: lines }, (_, index) => `export const value${index} = "${lorem(seed + index, 32)}"`).join(
|
||||||
|
"\n",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function turn(index: number): Message[] {
|
||||||
|
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
|
||||||
|
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
|
||||||
|
const parts = [
|
||||||
|
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
|
||||||
|
...(index % 3 === 0
|
||||||
|
? [
|
||||||
|
toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
|
||||||
|
toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
|
||||||
|
toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
|
||||||
|
toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
textPart(index, 2, 160 + (index % 6) * 90),
|
||||||
|
...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
|
||||||
|
...(index % 6 === 0
|
||||||
|
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||||
|
: []),
|
||||||
|
...(index % 8 === 0
|
||||||
|
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||||
|
: []),
|
||||||
|
...(index % 7 === 0
|
||||||
|
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
||||||
|
: []),
|
||||||
|
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
|
||||||
|
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
|
||||||
|
...(index % 13 === 0
|
||||||
|
? [
|
||||||
|
toolPart(
|
||||||
|
index,
|
||||||
|
11,
|
||||||
|
"question",
|
||||||
|
{ questions: [{ question: "Use generated fixture?" }, { question: "Keep same row shape?" }] },
|
||||||
|
120,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(index % 17 === 0
|
||||||
|
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
return [user, assistantMessage(targetID, index, user.info.id, parts)]
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
|
||||||
|
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
||||||
|
userMessage(sourceID, index + 1000, 120),
|
||||||
|
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
|
||||||
|
]).flat()
|
||||||
|
|
||||||
|
function renderable(part: MessagePart) {
|
||||||
|
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||||
|
if (part.type === "text") return !!part.text.trim()
|
||||||
|
if (part.type === "reasoning") return !!part.text.trim()
|
||||||
|
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderedParts(message: Message) {
|
||||||
|
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fixture = {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "smoke-project",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||||
|
},
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: sourceID,
|
||||||
|
slug: "source",
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title: "Uncommitted changes inquiry",
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: targetID,
|
||||||
|
slug: "target",
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title: "Example Game: sample jump movement & sample physics analysis",
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000001000, updated: 1700000001000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sourceID,
|
||||||
|
targetID,
|
||||||
|
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
|
||||||
|
expected: {
|
||||||
|
sourceTitle: "Uncommitted changes inquiry",
|
||||||
|
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
||||||
|
targetMessageIDs: targetMessages
|
||||||
|
.filter((message) => message.info.role === "user")
|
||||||
|
.map((message) => message.info.id),
|
||||||
|
targetPartIDs: targetMessages.flatMap((message) =>
|
||||||
|
orderedParts(message)
|
||||||
|
.filter(renderable)
|
||||||
|
.map((part) => part.id),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageMessages(sessionID: string, limit: number, before?: string) {
|
||||||
|
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
|
||||||
|
const end = before
|
||||||
|
? Math.max(
|
||||||
|
0,
|
||||||
|
messages.findIndex((message) => message.info.id === before),
|
||||||
|
)
|
||||||
|
: messages.length
|
||||||
|
const start = Math.max(0, end - limit)
|
||||||
|
return {
|
||||||
|
items: messages.slice(start, end),
|
||||||
|
cursor: start > 0 ? messages[start]!.info.id : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
|
import { fixture, pageMessages } from "./session-timeline.fixture"
|
||||||
|
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
|
||||||
|
const forbiddenText = ["Load details", "Show earlier steps"]
|
||||||
|
|
||||||
|
type SmokeState = {
|
||||||
|
ids: string[]
|
||||||
|
visibleIds: string[]
|
||||||
|
messageIds: string[]
|
||||||
|
visibleMessageIds: string[]
|
||||||
|
topVisibleId?: string
|
||||||
|
signature: string
|
||||||
|
scrollTop: number
|
||||||
|
scrollHeight: number
|
||||||
|
clientHeight: number
|
||||||
|
errorToasts: string[]
|
||||||
|
forbiddenText: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type SmokeWindow = Window & {
|
||||||
|
__timelineSmokeState?: () => SmokeState
|
||||||
|
__timelineSmokeErrorToasts?: string[]
|
||||||
|
__timelineSmokeForbiddenText?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("smoke: session timeline", () => {
|
||||||
|
test.setTimeout(240_000)
|
||||||
|
|
||||||
|
test("renders seeded timeline in order while paging through history", async ({ page }) => {
|
||||||
|
const errors = trackPageErrors(page)
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
sessions: fixture.sessions,
|
||||||
|
provider: fixture.provider,
|
||||||
|
directory: fixture.directory,
|
||||||
|
project: fixture.project,
|
||||||
|
pageMessages,
|
||||||
|
})
|
||||||
|
await configureSmokePage(page)
|
||||||
|
|
||||||
|
await openProject(page, "SmokeProject")
|
||||||
|
await navigateToSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
||||||
|
await expectSessionReady(page, "smoke-project")
|
||||||
|
await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||||
|
const expectedPartIDs = fixture.expected.targetPartIDs
|
||||||
|
const expectedMessageIDs = fixture.expected.targetMessageIDs
|
||||||
|
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
|
||||||
|
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function configureSmokePage(page: Page) {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"settings.v3",
|
||||||
|
JSON.stringify({
|
||||||
|
general: {
|
||||||
|
editToolPartsExpanded: true,
|
||||||
|
shellToolPartsExpanded: true,
|
||||||
|
showReasoningSummaries: true,
|
||||||
|
showSessionProgressBar: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const smoke = window as SmokeWindow
|
||||||
|
smoke.__timelineSmokeErrorToasts = []
|
||||||
|
smoke.__timelineSmokeForbiddenText = []
|
||||||
|
const partSelector = "[data-timeline-part-id], [data-timeline-part-ids]"
|
||||||
|
const idsOf = (el: HTMLElement) =>
|
||||||
|
[el.dataset.timelinePartId, ...(el.dataset.timelinePartIds?.split(",") ?? [])].filter((id): id is string => !!id)
|
||||||
|
|
||||||
|
smoke.__timelineSmokeState = () => {
|
||||||
|
const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
|
||||||
|
el.querySelector("[data-timeline-row], [data-session-title]"),
|
||||||
|
)
|
||||||
|
if (!scroller) {
|
||||||
|
return {
|
||||||
|
ids: [],
|
||||||
|
visibleIds: [],
|
||||||
|
messageIds: [],
|
||||||
|
visibleMessageIds: [],
|
||||||
|
topVisibleId: undefined,
|
||||||
|
signature: "",
|
||||||
|
scrollTop: 0,
|
||||||
|
scrollHeight: 0,
|
||||||
|
clientHeight: 0,
|
||||||
|
errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
|
||||||
|
forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids: string[] = []
|
||||||
|
const visibleIds: string[] = []
|
||||||
|
const scrollerRect = scroller.getBoundingClientRect()
|
||||||
|
let topVisibleId: string | undefined
|
||||||
|
for (const el of scroller.querySelectorAll<HTMLElement>(partSelector)) {
|
||||||
|
const next = idsOf(el)
|
||||||
|
ids.push(...next)
|
||||||
|
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) {
|
||||||
|
if (!topVisibleId) topVisibleId = next[0]
|
||||||
|
visibleIds.push(...next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageIds: string[] = []
|
||||||
|
const visibleMessageIds: string[] = []
|
||||||
|
const rows = [...scroller.querySelectorAll<HTMLElement>("[data-message-id]")].map((el) => {
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
const id = el.dataset.messageId
|
||||||
|
if (id) {
|
||||||
|
messageIds.push(id)
|
||||||
|
if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) visibleMessageIds.push(id)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
top: Math.round(rect.top),
|
||||||
|
bottom: Math.round(rect.bottom),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const signature = JSON.stringify({
|
||||||
|
top: Math.round(scroller.scrollTop),
|
||||||
|
height: Math.round(scroller.scrollHeight),
|
||||||
|
rows,
|
||||||
|
ids,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
ids,
|
||||||
|
visibleIds,
|
||||||
|
messageIds,
|
||||||
|
visibleMessageIds,
|
||||||
|
topVisibleId,
|
||||||
|
signature,
|
||||||
|
scrollTop: Math.round(scroller.scrollTop),
|
||||||
|
scrollHeight: Math.round(scroller.scrollHeight),
|
||||||
|
clientHeight: Math.round(scroller.clientHeight),
|
||||||
|
errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
|
||||||
|
forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let recordFrame: number | undefined
|
||||||
|
const record = () => {
|
||||||
|
for (const toast of document.querySelectorAll<HTMLElement>('[data-component="toast"][data-variant="error"]')) {
|
||||||
|
const text = toast.textContent?.trim()
|
||||||
|
if (text && !smoke.__timelineSmokeErrorToasts!.includes(text)) smoke.__timelineSmokeErrorToasts!.push(text)
|
||||||
|
}
|
||||||
|
const text = document.body?.textContent ?? ""
|
||||||
|
for (const value of ["Load details", "Show earlier steps"]) {
|
||||||
|
if (text.includes(value) && !smoke.__timelineSmokeForbiddenText!.includes(value)) {
|
||||||
|
smoke.__timelineSmokeForbiddenText!.push(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const start = () => {
|
||||||
|
const root = document.documentElement ?? document.body
|
||||||
|
if (!root) return
|
||||||
|
new MutationObserver(() => {
|
||||||
|
if (recordFrame) return
|
||||||
|
recordFrame = requestAnimationFrame(() => {
|
||||||
|
recordFrame = undefined
|
||||||
|
record()
|
||||||
|
})
|
||||||
|
}).observe(root, { childList: true, subtree: true })
|
||||||
|
record()
|
||||||
|
}
|
||||||
|
if (document.documentElement ?? document.body) start()
|
||||||
|
else document.addEventListener("DOMContentLoaded", start, { once: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectCanScrollToStart(
|
||||||
|
page: Page,
|
||||||
|
expectedPartIDs: string[],
|
||||||
|
expectedMessageIDs: string[],
|
||||||
|
errors: string[],
|
||||||
|
) {
|
||||||
|
await pointAtTimeline(page)
|
||||||
|
const seenParts = new Set<string>()
|
||||||
|
const seenMessages = new Set<string>()
|
||||||
|
const samples: TraversalSample[] = []
|
||||||
|
let current = await timelineState(page)
|
||||||
|
let unchangedAtTop = 0
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 600; attempt++) {
|
||||||
|
collectSeen(current, seenParts, seenMessages)
|
||||||
|
samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
|
||||||
|
expectNoSmokeErrors(errors, current.errorToasts, current.forbiddenText)
|
||||||
|
expectOrderedIDs(expectedPartIDs, current.ids, "mounted part")
|
||||||
|
expectOrderedIDs(expectedPartIDs, current.visibleIds, "visible part")
|
||||||
|
expectOrderedIDs(expectedMessageIDs, unique(current.messageIds), "mounted message")
|
||||||
|
expectOrderedIDs(expectedMessageIDs, unique(current.visibleMessageIds), "visible message")
|
||||||
|
|
||||||
|
if (
|
||||||
|
current.scrollTop <= 1 &&
|
||||||
|
seenParts.size === expectedPartIDs.length &&
|
||||||
|
seenMessages.size === expectedMessageIDs.length
|
||||||
|
) {
|
||||||
|
expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = current
|
||||||
|
const changed = await scrollTimelineUp(page, current)
|
||||||
|
current = await timelineState(page)
|
||||||
|
if (!changed && current.signature === before.signature && current.scrollTop <= 1) unchangedAtTop++
|
||||||
|
else unchangedAtTop = 0
|
||||||
|
if (unchangedAtTop >= 2) break
|
||||||
|
}
|
||||||
|
|
||||||
|
collectSeen(current, seenParts, seenMessages)
|
||||||
|
samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
|
||||||
|
expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function timelineState(page: Page) {
|
||||||
|
return page.evaluate(
|
||||||
|
() =>
|
||||||
|
(window as SmokeWindow).__timelineSmokeState?.() ?? {
|
||||||
|
ids: [],
|
||||||
|
visibleIds: [],
|
||||||
|
messageIds: [],
|
||||||
|
visibleMessageIds: [],
|
||||||
|
topVisibleId: undefined,
|
||||||
|
signature: "",
|
||||||
|
scrollTop: 0,
|
||||||
|
scrollHeight: 0,
|
||||||
|
clientHeight: 0,
|
||||||
|
errorToasts: [],
|
||||||
|
forbiddenText: [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function timelineScroller(page: Page) {
|
||||||
|
return page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pointAtTimeline(page: Page) {
|
||||||
|
const box = await timelineScroller(page).boundingBox()
|
||||||
|
if (!box) throw new Error("Timeline scroller is not visible")
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrollTimelineUp(page: Page, before: SmokeState) {
|
||||||
|
return page.evaluate(
|
||||||
|
(prev) =>
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
|
||||||
|
el.querySelector("[data-timeline-row], [data-session-title]"),
|
||||||
|
)
|
||||||
|
if (!scroller) {
|
||||||
|
resolve(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1, deltaMode: 0 }))
|
||||||
|
scroller.scrollTop = Math.max(0, scroller.scrollTop - Math.max(80, Math.round(scroller.clientHeight * 0.45)))
|
||||||
|
|
||||||
|
const read = () => (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
|
||||||
|
let frames = 0
|
||||||
|
let stableFrames = 0
|
||||||
|
let last = ""
|
||||||
|
let changed = false
|
||||||
|
const check = () => {
|
||||||
|
const current = read()
|
||||||
|
if (current !== prev) changed = true
|
||||||
|
if (current === last) stableFrames++
|
||||||
|
else {
|
||||||
|
stableFrames = 0
|
||||||
|
last = current
|
||||||
|
}
|
||||||
|
if (changed && stableFrames >= 2) {
|
||||||
|
resolve(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
frames++
|
||||||
|
if (frames >= 30) {
|
||||||
|
resolve(changed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestAnimationFrame(check)
|
||||||
|
}
|
||||||
|
requestAnimationFrame(check)
|
||||||
|
}),
|
||||||
|
before.signature,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectOrderedIDs(expected: string[], actual: string[], label: string) {
|
||||||
|
expect(actual.length, `${label} ids should not be empty`).toBeGreaterThan(0)
|
||||||
|
const actualSet = new Set(actual)
|
||||||
|
expect(actual, `${label} ids`).toEqual(expected.filter((id) => actualSet.has(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function unique(values: string[]) {
|
||||||
|
return values.filter((value, index) => values.indexOf(value) === index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSeen(state: SmokeState, seenParts: Set<string>, seenMessages: Set<string>) {
|
||||||
|
for (const id of state.ids) seenParts.add(id)
|
||||||
|
for (const id of state.visibleIds) seenParts.add(id)
|
||||||
|
for (const id of state.messageIds) seenMessages.add(id)
|
||||||
|
for (const id of state.visibleMessageIds) seenMessages.add(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TraversalSample = ReturnType<typeof sampleTraversal>
|
||||||
|
|
||||||
|
function sampleTraversal(state: SmokeState, seenParts: number, seenMessages: number) {
|
||||||
|
return {
|
||||||
|
seenParts,
|
||||||
|
seenMessages,
|
||||||
|
mounted: state.ids.length,
|
||||||
|
visible: state.visibleIds.length,
|
||||||
|
mountedMessages: unique(state.messageIds).length,
|
||||||
|
visibleMessages: unique(state.visibleMessageIds).length,
|
||||||
|
top: state.scrollTop,
|
||||||
|
height: state.scrollHeight,
|
||||||
|
first: state.ids[0],
|
||||||
|
last: state.ids.at(-1),
|
||||||
|
topVisible: state.topVisibleId,
|
||||||
|
visibleFirst: state.visibleIds[0],
|
||||||
|
visibleLast: state.visibleIds.at(-1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleSummary(samples: TraversalSample[]) {
|
||||||
|
return samples
|
||||||
|
.filter((_, index) => index % Math.max(1, Math.floor(samples.length / 8)) === 0 || index === samples.length - 1)
|
||||||
|
.map(
|
||||||
|
(sample, index) =>
|
||||||
|
`${index}: seenParts=${sample.seenParts} seenMessages=${sample.seenMessages} mounted=${sample.mounted}/${sample.mountedMessages} visible=${sample.visible}/${sample.visibleMessages} top=${sample.top}/${sample.height} first=${sample.first} last=${sample.last} topVisible=${sample.topVisible} visible=${sample.visibleFirst}..${sample.visibleLast}`,
|
||||||
|
)
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForTimelineStable(page: Page) {
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const a = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const b = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
resolve(!!a && a === b && b === ((window as SmokeWindow).__timelineSmokeState?.().signature ?? "")),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectSessionTimelineReady(
|
||||||
|
page: Page,
|
||||||
|
expectedPartIDs: string[],
|
||||||
|
expectedMessageIDs: string[],
|
||||||
|
errors: string[],
|
||||||
|
) {
|
||||||
|
await waitForTimelineStable(page)
|
||||||
|
for (const text of forbiddenText) await expect(page.getByText(text)).toHaveCount(0)
|
||||||
|
const currentState = await timelineState(page)
|
||||||
|
expectNoSmokeErrors(errors, currentState.errorToasts, currentState.forbiddenText)
|
||||||
|
expectOrderedIDs(expectedPartIDs, currentState.ids, "mounted part")
|
||||||
|
expectOrderedIDs(expectedPartIDs, currentState.visibleIds, "visible part")
|
||||||
|
expectOrderedIDs(expectedMessageIDs, unique(currentState.messageIds), "mounted message")
|
||||||
|
expectOrderedIDs(expectedMessageIDs, unique(currentState.visibleMessageIds), "visible message")
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectCompleteScroll(
|
||||||
|
state: SmokeState,
|
||||||
|
expectedPartIDs: string[],
|
||||||
|
expectedMessageIDs: string[],
|
||||||
|
seenParts: Set<string>,
|
||||||
|
seenMessages: Set<string>,
|
||||||
|
samples: TraversalSample[],
|
||||||
|
) {
|
||||||
|
expect(state.scrollTop, `timeline should reach the start\n${sampleSummary(samples)}`).toBeLessThanOrEqual(1)
|
||||||
|
expect(
|
||||||
|
expectedPartIDs.filter((id) => !seenParts.has(id)),
|
||||||
|
`missing visible timeline parts\n${sampleSummary(samples)}`,
|
||||||
|
).toEqual([])
|
||||||
|
expect(
|
||||||
|
expectedMessageIDs.filter((id) => !seenMessages.has(id)),
|
||||||
|
`missing visible messages\n${sampleSummary(samples)}`,
|
||||||
|
).toEqual([])
|
||||||
|
expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length)
|
||||||
|
expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length)
|
||||||
|
expect(expectedPartIDs.length).toBe(331)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openProject(page: Page, projectName: string) {
|
||||||
|
await page.goto("/")
|
||||||
|
await page.getByRole("button", { name: new RegExp(projectName, "i") }).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateToSession(page: Page, sessionId: string, expectedTitle: string) {
|
||||||
|
// Use evaluate to click to avoid strict visibility/animation issues during rapid e2e navigation
|
||||||
|
await page
|
||||||
|
.locator(`a[href*="${sessionId}"]`)
|
||||||
|
.first()
|
||||||
|
.evaluate((el) => (el as HTMLElement).click())
|
||||||
|
await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectSessionReady(page: Page, projectName: string) {
|
||||||
|
await expect(page.getByText(projectName).first()).toBeVisible()
|
||||||
|
await expect(page.getByText("Ask anything...")).toBeVisible()
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { test } from "@playwright/test"
|
|
||||||
|
|
||||||
test(
|
|
||||||
"test something cool",
|
|
||||||
{
|
|
||||||
annotation: { type: "todo" },
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
test.fixme()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test"
|
||||||
|
|
||||||
|
export function trackPageErrors(page: Page) {
|
||||||
|
const errors: string[] = []
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() === "error") errors.push(message.text())
|
||||||
|
})
|
||||||
|
page.on("pageerror", (error) => errors.push(error.stack ?? error.message))
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expectNoSmokeErrors(consoleErrors: string[], toastErrors: string[], forbiddenText: string[]) {
|
||||||
|
expect({ consoleErrors, toastErrors, forbiddenText }).toEqual({
|
||||||
|
consoleErrors: [],
|
||||||
|
toastErrors: [],
|
||||||
|
forbiddenText: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { Page, Route } from "@playwright/test"
|
||||||
|
|
||||||
|
const emptyList = new Set([
|
||||||
|
"/skill",
|
||||||
|
"/command",
|
||||||
|
"/lsp",
|
||||||
|
"/formatter",
|
||||||
|
"/permission",
|
||||||
|
"/question",
|
||||||
|
"/vcs/status",
|
||||||
|
"/vcs/diff",
|
||||||
|
])
|
||||||
|
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"])
|
||||||
|
|
||||||
|
export interface MockServerConfig {
|
||||||
|
provider: unknown
|
||||||
|
directory: string
|
||||||
|
project: unknown
|
||||||
|
sessions: ({ id: string } & Record<string, unknown>)[]
|
||||||
|
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
|
const staticRoutes: Record<string, unknown> = {
|
||||||
|
"/provider": config.provider,
|
||||||
|
"/path": {
|
||||||
|
state: config.directory,
|
||||||
|
config: config.directory,
|
||||||
|
worktree: config.directory,
|
||||||
|
directory: config.directory,
|
||||||
|
home: "C:/OpenCode",
|
||||||
|
},
|
||||||
|
"/project": [config.project],
|
||||||
|
"/project/current": config.project,
|
||||||
|
"/agent": [{ name: "build", mode: "primary" }],
|
||||||
|
"/vcs": { branch: "main", default_branch: "main" },
|
||||||
|
"/session": config.sessions,
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.route("**/*", async (route) => {
|
||||||
|
const url = new URL(route.request().url())
|
||||||
|
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||||
|
if (url.port !== targetPort) return route.fallback()
|
||||||
|
|
||||||
|
const path = url.pathname
|
||||||
|
if (path === "/global/event" || path === "/event") return sse(route)
|
||||||
|
if (emptyObject.has(path)) return json(route, {})
|
||||||
|
if (emptyList.has(path)) return json(route, [])
|
||||||
|
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||||
|
|
||||||
|
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
|
||||||
|
if (sessionMatch) {
|
||||||
|
const session = config.sessions.find((s) => s.id === sessionMatch[1])
|
||||||
|
return json(route, session ?? {})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
|
||||||
|
|
||||||
|
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||||
|
if (messagesMatch) {
|
||||||
|
const limit = Number(url.searchParams.get("limit") ?? 80)
|
||||||
|
const before = url.searchParams.get("before") ?? undefined
|
||||||
|
const pageData = config.pageMessages(messagesMatch[1], limit, before)
|
||||||
|
return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(route, {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(route: Route, body: unknown, headers?: Record<string, string>) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: {
|
||||||
|
"access-control-allow-origin": "*",
|
||||||
|
"access-control-expose-headers": "x-next-cursor",
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body ?? null),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sse(route: Route) {
|
||||||
|
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"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.15.4",
|
"version": "1.15.5",
|
||||||
"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.15.4",
|
"version": "1.15.5",
|
||||||
"$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.15.4",
|
"version": "1.15.5",
|
||||||
"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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://opencode.ai",
|
"homepage": "https://opencode.ai",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"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.15.4"
|
version = "1.15.5"
|
||||||
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.15.4/opencode-darwin-arm64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/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.15.4/opencode-darwin-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/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.15.4/opencode-linux-arm64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/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.15.4/opencode-linux-x64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/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.15.4/opencode-windows-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/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.15.4",
|
"version": "1.15.5",
|
||||||
"$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.15.4",
|
"version": "1.15.5",
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export const TextDelta = Schema.Struct({
|
|||||||
type: Schema.tag("text-delta"),
|
type: Schema.tag("text-delta"),
|
||||||
id: ContentBlockID,
|
id: ContentBlockID,
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.TextDelta" })
|
}).annotate({ identifier: "LLM.Event.TextDelta" })
|
||||||
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
|
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
|
||||||
|
|
||||||
@@ -112,6 +113,7 @@ export const ReasoningDelta = Schema.Struct({
|
|||||||
type: Schema.tag("reasoning-delta"),
|
type: Schema.tag("reasoning-delta"),
|
||||||
id: ContentBlockID,
|
id: ContentBlockID,
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.ReasoningDelta" })
|
}).annotate({ identifier: "LLM.Event.ReasoningDelta" })
|
||||||
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
|
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.15.4",
|
"version": "1.15.5",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
"test": "bun test --timeout 30000",
|
"test": "bun test --timeout 30000",
|
||||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||||
"test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip",
|
"test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip",
|
||||||
|
"bench:test": "bun run script/bench-test-suite.ts",
|
||||||
|
"profile:test": "bun run script/profile-test-files.ts",
|
||||||
"build": "bun run script/build.ts",
|
"build": "bun run script/build.ts",
|
||||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||||
"dev": "bun run --conditions=browser ./src/index.ts",
|
"dev": "bun run --conditions=browser ./src/index.ts",
|
||||||
@@ -37,8 +39,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.28.4",
|
"@babel/core": "7.28.4",
|
||||||
"@octokit/webhooks-types": "7.6.1",
|
"@octokit/webhooks-types": "7.6.1",
|
||||||
"@opencode-ai/script": "workspace:*",
|
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
|
"@opencode-ai/http-recorder": "workspace:*",
|
||||||
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||||
@@ -99,6 +102,7 @@
|
|||||||
"@octokit/graphql": "9.0.2",
|
"@octokit/graphql": "9.0.2",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
"@openauthjs/openauth": "catalog:",
|
"@openauthjs/openauth": "catalog:",
|
||||||
|
"@opencode-ai/llm": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
|
|||||||
@@ -296,5 +296,81 @@ export default {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
filetype: "elixir",
|
||||||
|
wasm: "https://github.com/elixir-lang/tree-sitter-elixir/releases/download/v0.3.5/tree-sitter-elixir.wasm",
|
||||||
|
queries: {
|
||||||
|
highlights: [
|
||||||
|
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/elixir/highlights.scm",
|
||||||
|
],
|
||||||
|
locals: [
|
||||||
|
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/elixir/locals.scm",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filetype: "fsharp",
|
||||||
|
wasm: "https://github.com/ionide/tree-sitter-fsharp/releases/download/0.3.0/tree-sitter-fsharp.wasm",
|
||||||
|
queries: {
|
||||||
|
highlights: [
|
||||||
|
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/fsharp/highlights.scm",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filetype: "r",
|
||||||
|
wasm: "https://github.com/r-lib/tree-sitter-r/releases/download/v1.2.0/tree-sitter-r.wasm",
|
||||||
|
queries: {
|
||||||
|
highlights: [
|
||||||
|
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/r/highlights.scm",
|
||||||
|
],
|
||||||
|
locals: [
|
||||||
|
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/r/locals.scm",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filetype: "make",
|
||||||
|
aliases: ["makefile"],
|
||||||
|
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-make/releases/download/v1.1.1/tree-sitter-make.wasm",
|
||||||
|
queries: {
|
||||||
|
highlights: [
|
||||||
|
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/make/highlights.scm",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filetype: "vim",
|
||||||
|
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-vim/releases/download/v0.8.1/tree-sitter-vim.wasm",
|
||||||
|
queries: {
|
||||||
|
highlights: [
|
||||||
|
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/vim/highlights.scm",
|
||||||
|
],
|
||||||
|
locals: [
|
||||||
|
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/vim/locals.scm",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filetype: "xml",
|
||||||
|
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-xml/releases/download/v0.7.0/tree-sitter-xml.wasm",
|
||||||
|
queries: {
|
||||||
|
highlights: [
|
||||||
|
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/xml/highlights.scm",
|
||||||
|
],
|
||||||
|
locals: [
|
||||||
|
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/xml/locals.scm",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filetype: "agda",
|
||||||
|
wasm: "https://github.com/tree-sitter/tree-sitter-agda/releases/download/v1.3.3/tree-sitter-agda.wasm",
|
||||||
|
queries: {
|
||||||
|
highlights: [
|
||||||
|
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/agda/highlights.scm",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Full-suite timing harness for the test-speed research in ../../perf/test-suite.md.
|
||||||
|
// Use this for periodic sanity checks; use profile-test-files.ts for discovery.
|
||||||
|
// Env: BENCH_WARMUPS=0 BENCH_RUNS=1 bun run bench:test
|
||||||
|
const warmups = Number(Bun.env.BENCH_WARMUPS ?? 0)
|
||||||
|
const runs = Number(Bun.env.BENCH_RUNS ?? 1)
|
||||||
|
const timings: number[] = []
|
||||||
|
|
||||||
|
if (!Number.isInteger(warmups) || warmups < 0) {
|
||||||
|
console.error("BENCH_WARMUPS must be a non-negative integer")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(runs) || runs < 1) {
|
||||||
|
console.error("BENCH_RUNS must be a positive integer")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const index of Array.from({ length: warmups + runs }, (_, index) => index)) {
|
||||||
|
const measured = index >= warmups
|
||||||
|
const label = measured ? `run ${index - warmups + 1}/${runs}` : `warmup ${index + 1}/${warmups}`
|
||||||
|
const start = performance.now()
|
||||||
|
console.log(`bench:test ${label}`)
|
||||||
|
|
||||||
|
const proc = Bun.spawn(["bun", "test", "--timeout", "30000"], {
|
||||||
|
cwd: import.meta.dir + "/..",
|
||||||
|
stdout: "inherit",
|
||||||
|
stderr: "inherit",
|
||||||
|
env: Bun.env,
|
||||||
|
})
|
||||||
|
|
||||||
|
const exitCode = await proc.exited
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
console.error(`bench:test failed during ${label} with exit code ${exitCode}`)
|
||||||
|
process.exit(exitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
const seconds = (performance.now() - start) / 1000
|
||||||
|
console.log(`bench:test ${label} ${seconds.toFixed(3)}s`)
|
||||||
|
if (measured) timings.push(seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = timings.toSorted((a, b) => a - b)
|
||||||
|
const median = sorted[Math.floor(sorted.length / 2)]
|
||||||
|
const mean = timings.reduce((sum, timing) => sum + timing, 0) / timings.length
|
||||||
|
const best = sorted[0] ?? median
|
||||||
|
const worst = sorted.at(-1) ?? median
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`bench:test median=${median.toFixed(3)}s mean=${mean.toFixed(3)}s best=${best.toFixed(3)}s worst=${worst.toFixed(3)}s`,
|
||||||
|
)
|
||||||
|
console.log(`METRIC test_suite_seconds=${median.toFixed(3)}`)
|
||||||
|
console.log(`METRIC test_suite_best_seconds=${best.toFixed(3)}`)
|
||||||
|
console.log(`METRIC test_suite_worst_seconds=${worst.toFixed(3)}`)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Per-file profiler for finding candidate test-speed work; see ../../perf/test-suite.md
|
||||||
|
// for the benchmark notes, kept wins, and discarded experiments.
|
||||||
|
// Example: TEST_PROFILE_GLOB='test/server/**/*.test.ts' TEST_PROFILE_TOP=15 bun run profile:test
|
||||||
|
const pattern = Bun.env.TEST_PROFILE_GLOB ?? "test/**/*.test.{ts,tsx}"
|
||||||
|
const limit = Number(Bun.env.TEST_PROFILE_LIMIT ?? 0)
|
||||||
|
const timeout = Bun.env.TEST_PROFILE_TIMEOUT ?? "30000"
|
||||||
|
const files = Array.fromAsync(new Bun.Glob(pattern).scan({ cwd: import.meta.dir + "/..", onlyFiles: true }))
|
||||||
|
.then((files) => files.toSorted())
|
||||||
|
.then((files) => (limit > 0 ? files.slice(0, limit) : files))
|
||||||
|
|
||||||
|
const results = []
|
||||||
|
for (const file of await files) {
|
||||||
|
const start = performance.now()
|
||||||
|
const proc = Bun.spawn(["bun", "test", "--timeout", timeout, file], {
|
||||||
|
cwd: import.meta.dir + "/..",
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
env: Bun.env,
|
||||||
|
})
|
||||||
|
const [output, error, exitCode] = await Promise.all([
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
proc.exited,
|
||||||
|
])
|
||||||
|
const seconds = (performance.now() - start) / 1000
|
||||||
|
results.push({ file, seconds, exitCode })
|
||||||
|
console.log(`${exitCode === 0 ? "PASS" : "FAIL"} ${seconds.toFixed(3)}s ${file}`)
|
||||||
|
if (exitCode !== 0) console.log((output + error).trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = results.toSorted((a, b) => b.seconds - a.seconds)
|
||||||
|
console.log("\nSlowest test files:")
|
||||||
|
for (const result of sorted.slice(0, Number(Bun.env.TEST_PROFILE_TOP ?? 20))) {
|
||||||
|
console.log(`${result.seconds.toFixed(3)}s ${result.exitCode === 0 ? "PASS" : "FAIL"} ${result.file}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sorted[0]) {
|
||||||
|
console.log(`METRIC slowest_test_file_seconds=${sorted[0].seconds.toFixed(3)}`)
|
||||||
|
console.log(`METRIC profiled_test_files=${results.length}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.some((result) => result.exitCode !== 0)) process.exit(1)
|
||||||
@@ -37,8 +37,16 @@ export interface Interface {
|
|||||||
properties: BusProperties<D>,
|
properties: BusProperties<D>,
|
||||||
options?: { id?: string },
|
options?: { id?: string },
|
||||||
) => Effect.Effect<void>
|
) => Effect.Effect<void>
|
||||||
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
|
// subscribe / subscribeAll are eager: the underlying PubSub subscription is
|
||||||
readonly subscribeAll: () => Stream.Stream<Payload>
|
// acquired in the caller's Scope at `yield*` time. Any publish after the
|
||||||
|
// yield is delivered, even if stream consumption starts later. The previous
|
||||||
|
// Stream-returning shape acquired the subscription lazily on first pull,
|
||||||
|
// opening a race window during which publishes were lost — see
|
||||||
|
// test/bus/bus-effect.test.ts RACE tests.
|
||||||
|
readonly subscribe: <D extends BusEvent.Definition>(
|
||||||
|
def: D,
|
||||||
|
) => Effect.Effect<Stream.Stream<Payload<D>>, never, Scope.Scope>
|
||||||
|
readonly subscribeAll: () => Effect.Effect<Stream.Stream<Payload>, never, Scope.Scope>
|
||||||
readonly subscribeCallback: <D extends BusEvent.Definition>(
|
readonly subscribeCallback: <D extends BusEvent.Definition>(
|
||||||
def: D,
|
def: D,
|
||||||
callback: (event: Payload<D>) => unknown,
|
callback: (event: Payload<D>) => unknown,
|
||||||
@@ -109,26 +117,26 @@ export const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
|
const subscribe = <D extends BusEvent.Definition>(
|
||||||
log.info("subscribing", { type: def.type })
|
def: D,
|
||||||
return Stream.unwrap(
|
): Effect.Effect<Stream.Stream<Payload<D>>, never, Scope.Scope> =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
log.info("subscribing", { type: def.type })
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(state)
|
||||||
const ps = yield* getOrCreate(s, def)
|
const ps = yield* getOrCreate(s, def)
|
||||||
return Stream.fromPubSub(ps)
|
const subscription = yield* PubSub.subscribe(ps)
|
||||||
}),
|
yield* Effect.addFinalizer(() => Effect.sync(() => log.info("unsubscribing", { type: def.type })))
|
||||||
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
|
return Stream.fromSubscription(subscription)
|
||||||
}
|
})
|
||||||
|
|
||||||
function subscribeAll(): Stream.Stream<Payload> {
|
const subscribeAll = (): Effect.Effect<Stream.Stream<Payload>, never, Scope.Scope> =>
|
||||||
log.info("subscribing", { type: "*" })
|
|
||||||
return Stream.unwrap(
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
log.info("subscribing", { type: "*" })
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(state)
|
||||||
return Stream.fromPubSub(s.wildcard)
|
const subscription = yield* PubSub.subscribe(s.wildcard)
|
||||||
}),
|
yield* Effect.addFinalizer(() => Effect.sync(() => log.info("unsubscribing", { type: "*" })))
|
||||||
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
|
return Stream.fromSubscription(subscription)
|
||||||
}
|
})
|
||||||
|
|
||||||
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
|
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
|
|||||||
@@ -218,6 +218,15 @@ export const RunCommand = effectCmd({
|
|||||||
type: "boolean",
|
type: "boolean",
|
||||||
describe: "show thinking blocks",
|
describe: "show thinking blocks",
|
||||||
})
|
})
|
||||||
|
.option("replay", {
|
||||||
|
type: "boolean",
|
||||||
|
default: false,
|
||||||
|
describe: "replay visible session history on interactive resume",
|
||||||
|
})
|
||||||
|
.option("replay-limit", {
|
||||||
|
type: "number",
|
||||||
|
describe: "cap visible interactive replay to the newest N messages",
|
||||||
|
})
|
||||||
.option("interactive", {
|
.option("interactive", {
|
||||||
alias: ["i"],
|
alias: ["i"],
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
@@ -269,6 +278,21 @@ export const RunCommand = effectCmd({
|
|||||||
die("--interactive cannot be used with --format json")
|
die("--interactive cannot be used with --format json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.replay && !args.interactive) {
|
||||||
|
die("--replay requires --interactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args["replay-limit"] !== undefined && !args.interactive) {
|
||||||
|
die("--replay-limit requires --interactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
args["replay-limit"] !== undefined &&
|
||||||
|
(!Number.isInteger(args["replay-limit"]) || args["replay-limit"] <= 0)
|
||||||
|
) {
|
||||||
|
die("--replay-limit must be a positive integer")
|
||||||
|
}
|
||||||
|
|
||||||
if (args.interactive && !process.stdout.isTTY) {
|
if (args.interactive && !process.stdout.isTTY) {
|
||||||
die("--interactive requires a TTY stdout")
|
die("--interactive requires a TTY stdout")
|
||||||
}
|
}
|
||||||
@@ -281,6 +305,8 @@ export const RunCommand = effectCmd({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const replay = args.replay || args["replay-limit"] !== undefined
|
||||||
|
|
||||||
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
|
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
|
||||||
const directory = (() => {
|
const directory = (() => {
|
||||||
if (!args.dir) return args.attach ? undefined : root
|
if (!args.dir) return args.attach ? undefined : root
|
||||||
@@ -786,6 +812,8 @@ export const RunCommand = effectCmd({
|
|||||||
sessionID,
|
sessionID,
|
||||||
sessionTitle: sess.title,
|
sessionTitle: sess.title,
|
||||||
resume: Boolean(args.session || args.continue) && !args.fork,
|
resume: Boolean(args.session || args.continue) && !args.fork,
|
||||||
|
replay,
|
||||||
|
replayLimit: args["replay-limit"],
|
||||||
agent,
|
agent,
|
||||||
model,
|
model,
|
||||||
variant: args.variant,
|
variant: args.variant,
|
||||||
@@ -821,6 +849,8 @@ export const RunCommand = effectCmd({
|
|||||||
agent: args.agent,
|
agent: args.agent,
|
||||||
model,
|
model,
|
||||||
variant: args.variant,
|
variant: args.variant,
|
||||||
|
replay,
|
||||||
|
replayLimit: args["replay-limit"],
|
||||||
files,
|
files,
|
||||||
initialInput,
|
initialInput,
|
||||||
thinking,
|
thinking,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { pathToFileURL } from "bun"
|
import { pathToFileURL } from "bun"
|
||||||
import { StyledText, bg, fg, type KeyBinding, type KeyEvent, type TextareaRenderable } from "@opentui/core"
|
import { StyledText, bg, fg, type KeyBinding, type KeyEvent, type TextareaRenderable } from "@opentui/core"
|
||||||
import { useKeyboard } from "@opentui/solid"
|
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
||||||
@@ -197,13 +197,45 @@ export function RunPromptBody(props: {
|
|||||||
onContentChange: () => void
|
onContentChange: () => void
|
||||||
bind: (area?: TextareaRenderable) => void
|
bind: (area?: TextareaRenderable) => void
|
||||||
}) {
|
}) {
|
||||||
|
const renderer = useRenderer()
|
||||||
let area: TextareaRenderable | undefined
|
let area: TextareaRenderable | undefined
|
||||||
|
let pasteTick: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
const refreshPasteLayout = () => {
|
||||||
|
if (pasteTick) {
|
||||||
|
clearTimeout(pasteTick)
|
||||||
|
}
|
||||||
|
|
||||||
|
pasteTick = setTimeout(() => {
|
||||||
|
pasteTick = undefined
|
||||||
|
if (!area || area.isDestroyed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paste can leave the textarea layout stale until the next edit.
|
||||||
|
area.getLayoutNode().markDirty()
|
||||||
|
renderer.requestRender()
|
||||||
|
void renderer
|
||||||
|
.idle()
|
||||||
|
.then(() => {
|
||||||
|
if (!area || area.isDestroyed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
props.onContentChange()
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
props.bind(area)
|
props.bind(area)
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
if (pasteTick) {
|
||||||
|
clearTimeout(pasteTick)
|
||||||
|
}
|
||||||
props.bind(undefined)
|
props.bind(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -226,6 +258,9 @@ export function RunPromptBody(props: {
|
|||||||
keyBindings={props.bindings()}
|
keyBindings={props.bindings()}
|
||||||
onSubmit={props.onSubmit}
|
onSubmit={props.onSubmit}
|
||||||
onKeyDown={props.onKeyDown}
|
onKeyDown={props.onKeyDown}
|
||||||
|
onPaste={() => {
|
||||||
|
refreshPasteLayout()
|
||||||
|
}}
|
||||||
onContentChange={props.onContentChange}
|
onContentChange={props.onContentChange}
|
||||||
ref={(next) => {
|
ref={(next) => {
|
||||||
area = next
|
area = next
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ type RunRuntimeInput = {
|
|||||||
files: RunInput["files"]
|
files: RunInput["files"]
|
||||||
initialInput?: string
|
initialInput?: string
|
||||||
thinking: boolean
|
thinking: boolean
|
||||||
|
replay?: boolean
|
||||||
|
replayLimit?: number
|
||||||
demo?: RunInput["demo"]
|
demo?: RunInput["demo"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +69,8 @@ type RunLocalInput = {
|
|||||||
files: RunInput["files"]
|
files: RunInput["files"]
|
||||||
initialInput?: string
|
initialInput?: string
|
||||||
thinking: boolean
|
thinking: boolean
|
||||||
|
replay?: boolean
|
||||||
|
replayLimit?: number
|
||||||
demo?: RunInput["demo"]
|
demo?: RunInput["demo"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,6 +494,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||||||
directory: ctx.directory,
|
directory: ctx.directory,
|
||||||
sessionID: state.sessionID,
|
sessionID: state.sessionID,
|
||||||
thinking: input.thinking,
|
thinking: input.thinking,
|
||||||
|
replay: input.replay,
|
||||||
|
replayLimit: input.replayLimit,
|
||||||
limits: () => state.limits,
|
limits: () => state.limits,
|
||||||
footer,
|
footer,
|
||||||
trace: log,
|
trace: log,
|
||||||
@@ -722,6 +728,8 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
|
|||||||
files: input.files,
|
files: input.files,
|
||||||
initialInput: input.initialInput,
|
initialInput: input.initialInput,
|
||||||
thinking: input.thinking,
|
thinking: input.thinking,
|
||||||
|
replay: input.replay,
|
||||||
|
replayLimit: input.replayLimit,
|
||||||
demo: input.demo,
|
demo: input.demo,
|
||||||
resolveSession: () => {
|
resolveSession: () => {
|
||||||
if (session) {
|
if (session) {
|
||||||
@@ -774,6 +782,8 @@ export async function runInteractiveMode(input: RunInput & { createSession?: Cre
|
|||||||
files: input.files,
|
files: input.files,
|
||||||
initialInput: input.initialInput,
|
initialInput: input.initialInput,
|
||||||
thinking: input.thinking,
|
thinking: input.thinking,
|
||||||
|
replay: input.replay,
|
||||||
|
replayLimit: input.replayLimit,
|
||||||
demo: input.demo,
|
demo: input.demo,
|
||||||
boot: async () => ({
|
boot: async () => ({
|
||||||
sdk: input.sdk,
|
sdk: input.sdk,
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
|
import { bootstrapSessionData, createSessionData, reduceSessionData, type SessionData } from "./session-data"
|
||||||
|
import { messagePrompt, type SessionMessages } from "./session.shared"
|
||||||
|
import type { FooterPatch, StreamCommit } from "./types"
|
||||||
|
|
||||||
|
type ReplayInput = {
|
||||||
|
messages: SessionMessages
|
||||||
|
permissions: PermissionRequest[]
|
||||||
|
questions: QuestionRequest[]
|
||||||
|
thinking: boolean
|
||||||
|
limits: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionReplay = {
|
||||||
|
data: SessionData
|
||||||
|
commits: StreamCommit[]
|
||||||
|
patch?: FooterPatch
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReplayMessage = {
|
||||||
|
commits: StreamCommit[]
|
||||||
|
patch?: FooterPatch
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(data: SessionData, event: Event, sessionID: string, thinking: boolean, limits: Record<string, number>) {
|
||||||
|
return reduceSessionData({
|
||||||
|
data,
|
||||||
|
event,
|
||||||
|
sessionID,
|
||||||
|
thinking,
|
||||||
|
limits,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergePatch(left: FooterPatch | undefined, right: FooterPatch | undefined) {
|
||||||
|
if (!left) {
|
||||||
|
return right
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!right) {
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...left,
|
||||||
|
...right,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function active(data: SessionData) {
|
||||||
|
return data.part.size > 0 || data.tools.size > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function replayPatch(data: SessionData, patch: FooterPatch | undefined) {
|
||||||
|
if (active(data)) {
|
||||||
|
if (!patch) {
|
||||||
|
return {
|
||||||
|
phase: "running",
|
||||||
|
} satisfies FooterPatch
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...patch,
|
||||||
|
phase: "running",
|
||||||
|
} satisfies FooterPatch
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.permissions.length > 0 || data.questions.length > 0) {
|
||||||
|
if (!patch) {
|
||||||
|
return {
|
||||||
|
phase: "idle",
|
||||||
|
} satisfies FooterPatch
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...patch,
|
||||||
|
phase: "idle",
|
||||||
|
} satisfies FooterPatch
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!patch) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...patch,
|
||||||
|
phase: "idle",
|
||||||
|
status: "",
|
||||||
|
} satisfies FooterPatch
|
||||||
|
}
|
||||||
|
|
||||||
|
function replayMessage(
|
||||||
|
data: SessionData,
|
||||||
|
message: SessionMessages[number],
|
||||||
|
thinking: boolean,
|
||||||
|
limits: Record<string, number>,
|
||||||
|
): ReplayMessage {
|
||||||
|
if (message.info.role === "user") {
|
||||||
|
const prompt = messagePrompt(message)
|
||||||
|
if (!prompt.text.trim()) {
|
||||||
|
return {
|
||||||
|
commits: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
commits: [
|
||||||
|
{
|
||||||
|
kind: "user",
|
||||||
|
text: prompt.text,
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: message.info.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const commits: StreamCommit[] = []
|
||||||
|
let patch: FooterPatch | undefined
|
||||||
|
|
||||||
|
const info = apply(
|
||||||
|
data,
|
||||||
|
{
|
||||||
|
id: `bootstrap:message:${message.info.id}`,
|
||||||
|
type: "message.updated",
|
||||||
|
properties: {
|
||||||
|
sessionID: message.info.sessionID,
|
||||||
|
info: message.info,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message.info.sessionID,
|
||||||
|
thinking,
|
||||||
|
limits,
|
||||||
|
)
|
||||||
|
commits.push(...info.commits)
|
||||||
|
patch = mergePatch(patch, info.footer?.patch)
|
||||||
|
|
||||||
|
for (const part of message.parts) {
|
||||||
|
const next = apply(
|
||||||
|
data,
|
||||||
|
{
|
||||||
|
id: `bootstrap:part:${part.id}`,
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
sessionID: part.sessionID,
|
||||||
|
part,
|
||||||
|
time: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message.info.sessionID,
|
||||||
|
thinking,
|
||||||
|
limits,
|
||||||
|
)
|
||||||
|
patch = mergePatch(patch, next.footer?.patch)
|
||||||
|
commits.push(...next.commits)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
commits,
|
||||||
|
patch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaySession(input: ReplayInput): SessionReplay {
|
||||||
|
const data = createSessionData()
|
||||||
|
const commits: StreamCommit[] = []
|
||||||
|
let patch: FooterPatch | undefined
|
||||||
|
|
||||||
|
bootstrapSessionData({
|
||||||
|
data,
|
||||||
|
messages: input.messages,
|
||||||
|
permissions: input.permissions,
|
||||||
|
questions: input.questions,
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const message of input.messages) {
|
||||||
|
const next = replayMessage(data, message, input.thinking, input.limits)
|
||||||
|
commits.push(...next.commits)
|
||||||
|
patch = mergePatch(patch, next.patch)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
commits,
|
||||||
|
patch: replayPatch(data, patch),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ function fileSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function prompt(msg: SessionMessages[number]): RunPrompt {
|
export function messagePrompt(msg: SessionMessages[number]): RunPrompt {
|
||||||
const parts: RunPrompt["parts"] = []
|
const parts: RunPrompt["parts"] = []
|
||||||
let text = msg.parts
|
let text = msg.parts
|
||||||
.filter((part): part is Extract<SessionMessages[number]["parts"][number], { type: "text" }> => {
|
.filter((part): part is Extract<SessionMessages[number]["parts"][number], { type: "text" }> => {
|
||||||
@@ -135,7 +135,7 @@ function turn(msg: SessionMessages[number]): Turn | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prompt: prompt(msg),
|
prompt: messagePrompt(msg),
|
||||||
provider: msg.info.model.providerID,
|
provider: msg.info.model.providerID,
|
||||||
model: msg.info.model.modelID,
|
model: msg.info.model.modelID,
|
||||||
variant: msg.info.model.variant,
|
variant: msg.info.model.variant,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
reduceSessionData,
|
reduceSessionData,
|
||||||
type SessionData,
|
type SessionData,
|
||||||
} from "./session-data"
|
} from "./session-data"
|
||||||
|
import { replaySession } from "./session-replay"
|
||||||
import {
|
import {
|
||||||
bootstrapSubagentCalls,
|
bootstrapSubagentCalls,
|
||||||
bootstrapSubagentData,
|
bootstrapSubagentData,
|
||||||
@@ -66,6 +67,8 @@ type StreamInput = {
|
|||||||
directory?: string
|
directory?: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
thinking: boolean
|
thinking: boolean
|
||||||
|
replay?: boolean
|
||||||
|
replayLimit?: number
|
||||||
limits: () => Record<string, number>
|
limits: () => Record<string, number>
|
||||||
footer: FooterApi
|
footer: FooterApi
|
||||||
trace?: Trace
|
trace?: Trace
|
||||||
@@ -432,7 +435,12 @@ function createLayer(input: StreamInput) {
|
|||||||
blockerTick: 0,
|
blockerTick: 0,
|
||||||
blockers: new Map(),
|
blockers: new Map(),
|
||||||
}
|
}
|
||||||
|
let booting = true
|
||||||
|
const buffered: Event[] = []
|
||||||
|
const replayedParts = new Set<string>()
|
||||||
const recovering = new Set<string>()
|
const recovering = new Set<string>()
|
||||||
|
const tracked = (sessionID: string | undefined) =>
|
||||||
|
sessionID === input.sessionID || (!!sessionID && state.subagent.tabs.has(sessionID))
|
||||||
const currentSubagentState = () => {
|
const currentSubagentState = () => {
|
||||||
if (state.selectedSubagent && !state.subagent.tabs.has(state.selectedSubagent)) {
|
if (state.selectedSubagent && !state.subagent.tabs.has(state.selectedSubagent)) {
|
||||||
state.selectedSubagent = undefined
|
state.selectedSubagent = undefined
|
||||||
@@ -550,11 +558,11 @@ function createLayer(input: StreamInput) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const messages = (sessionID: string, limit: number) =>
|
const messages = (sessionID: string, limit?: number) =>
|
||||||
Effect.promise(() =>
|
Effect.promise(() =>
|
||||||
input.sdk.session.messages({
|
input.sdk.session.messages({
|
||||||
sessionID,
|
sessionID,
|
||||||
limit,
|
...(typeof limit === "number" ? { limit } : {}),
|
||||||
}),
|
}),
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.map((item) => item.data ?? []),
|
Effect.map((item) => item.data ?? []),
|
||||||
@@ -596,7 +604,14 @@ function createLayer(input: StreamInput) {
|
|||||||
const bootstrap = Effect.fn("RunStreamTransport.bootstrap")(function* () {
|
const bootstrap = Effect.fn("RunStreamTransport.bootstrap")(function* () {
|
||||||
const [messagesList, children, permissions, questions] = yield* Effect.all(
|
const [messagesList, children, permissions, questions] = yield* Effect.all(
|
||||||
[
|
[
|
||||||
messages(input.sessionID, SUBAGENT_BOOTSTRAP_LIMIT),
|
messages(
|
||||||
|
input.sessionID,
|
||||||
|
input.replay
|
||||||
|
? input.replayLimit === undefined
|
||||||
|
? undefined
|
||||||
|
: Math.max(input.replayLimit, SUBAGENT_BOOTSTRAP_LIMIT)
|
||||||
|
: SUBAGENT_BOOTSTRAP_LIMIT,
|
||||||
|
),
|
||||||
Effect.promise(() =>
|
Effect.promise(() =>
|
||||||
input.sdk.session.children({
|
input.sdk.session.children({
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
@@ -619,12 +634,52 @@ function createLayer(input: StreamInput) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const sessionPermissions = permissions.filter((item) => item.sessionID === input.sessionID)
|
||||||
|
const sessionQuestions = questions.filter((item) => item.sessionID === input.sessionID)
|
||||||
|
const history = input.replay
|
||||||
|
? replaySession({
|
||||||
|
messages: messagesList,
|
||||||
|
permissions: sessionPermissions,
|
||||||
|
questions: sessionQuestions,
|
||||||
|
thinking: input.thinking,
|
||||||
|
limits: input.limits(),
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
const replay =
|
||||||
|
history && input.replayLimit !== undefined && messagesList.length > input.replayLimit
|
||||||
|
? replaySession({
|
||||||
|
messages: messagesList.slice(-input.replayLimit),
|
||||||
|
permissions: sessionPermissions,
|
||||||
|
questions: sessionQuestions,
|
||||||
|
thinking: input.thinking,
|
||||||
|
limits: input.limits(),
|
||||||
|
})
|
||||||
|
: history
|
||||||
|
|
||||||
|
replayedParts.clear()
|
||||||
|
if (history) {
|
||||||
|
state.data = history.data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!history) {
|
||||||
bootstrapSessionData({
|
bootstrapSessionData({
|
||||||
data: state.data,
|
data: state.data,
|
||||||
messages: messagesList,
|
messages: messagesList,
|
||||||
permissions: permissions.filter((item) => item.sessionID === input.sessionID),
|
permissions: sessionPermissions,
|
||||||
questions: questions.filter((item) => item.sessionID === input.sessionID),
|
questions: sessionQuestions,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replay) {
|
||||||
|
for (const [partID] of replay.data.text) {
|
||||||
|
if (!replay.data.part.has(partID)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
replayedParts.add(partID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bootstrapSubagentData({
|
bootstrapSubagentData({
|
||||||
data: state.subagent,
|
data: state.subagent,
|
||||||
messages: messagesList,
|
messages: messagesList,
|
||||||
@@ -632,6 +687,7 @@ function createLayer(input: StreamInput) {
|
|||||||
permissions,
|
permissions,
|
||||||
questions,
|
questions,
|
||||||
})
|
})
|
||||||
|
clearFinishedSubagents(state.subagent)
|
||||||
|
|
||||||
for (const request of [
|
for (const request of [
|
||||||
...state.data.permissions,
|
...state.data.permissions,
|
||||||
@@ -642,9 +698,29 @@ function createLayer(input: StreamInput) {
|
|||||||
seedBlocker(request.id)
|
seedBlocker(request.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (replay) {
|
||||||
|
const activeCommitIDs = new Set([...state.data.part.keys(), ...state.data.tools])
|
||||||
|
for (const commit of replay.commits) {
|
||||||
|
input.trace?.write("ui.commit", commit)
|
||||||
|
input.footer.append(commit)
|
||||||
|
|
||||||
|
if (commit.partID && activeCommitIDs.has(commit.partID)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* Effect.promise(() => input.footer.idle()).pipe(Effect.orElseSucceed(() => undefined))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const snapshot = currentSubagentState()
|
const snapshot = currentSubagentState()
|
||||||
traceTabs(input.trace, [], snapshot.tabs)
|
traceTabs(input.trace, [], snapshot.tabs)
|
||||||
syncFooter([], undefined, snapshot)
|
syncFooter([], replay?.patch, snapshot)
|
||||||
|
if (replay) {
|
||||||
|
yield* Effect.promise(() => input.footer.idle()).pipe(Effect.orElseSucceed(() => undefined))
|
||||||
|
}
|
||||||
|
|
||||||
|
booting = false
|
||||||
|
yield* drainBuffered()
|
||||||
|
|
||||||
const sessions = [...state.subagent.tabs.keys()]
|
const sessions = [...state.subagent.tabs.keys()]
|
||||||
if (sessions.length === 0) {
|
if (sessions.length === 0) {
|
||||||
@@ -738,35 +814,18 @@ function createLayer(input: StreamInput) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const watch = Effect.fn("RunStreamTransport.watch")(() =>
|
const applyEvent = Effect.fn("RunStreamTransport.applyEvent")(function* (event: Event) {
|
||||||
Stream.fromAsyncIterable(events.stream, (error) =>
|
if (event.type === "message.part.delta" && event.properties.sessionID === input.sessionID) {
|
||||||
error instanceof Error ? error : new Error(String(error)),
|
if (replayedParts.has(event.properties.partID)) {
|
||||||
).pipe(
|
const seen = state.data.text.get(event.properties.partID) ?? ""
|
||||||
Stream.takeUntil(() => input.footer.isClosed || abort.signal.aborted),
|
if (seen.endsWith(event.properties.delta)) {
|
||||||
Stream.runForEach(
|
|
||||||
Effect.fn("RunStreamTransport.event")(function* (item: unknown) {
|
|
||||||
if (input.footer.isClosed) {
|
|
||||||
abort.abort()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMatchingDisposeEvent(item, input.directory)) {
|
replayedParts.delete(event.properties.partID)
|
||||||
yield* fail(new Error("instance disposed"))
|
}
|
||||||
yield* closeScope()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = globalPayloadEvent(item)
|
|
||||||
if (!event) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionID = sid(event)
|
|
||||||
if (sessionID !== input.sessionID && (!sessionID || !state.subagent.tabs.has(sessionID))) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
input.trace?.write("recv.event", event)
|
|
||||||
trackBlocker(event)
|
trackBlocker(event)
|
||||||
|
|
||||||
const prev = event.type === "message.part.updated" ? listSubagentTabs(state.subagent) : undefined
|
const prev = event.type === "message.part.updated" ? listSubagentTabs(state.subagent) : undefined
|
||||||
@@ -809,6 +868,75 @@ function createLayer(input: StreamInput) {
|
|||||||
|
|
||||||
touch(event)
|
touch(event)
|
||||||
yield* mark(event)
|
yield* mark(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
const drainBuffered = Effect.fn("RunStreamTransport.drainBuffered")(function* () {
|
||||||
|
let pending = buffered.splice(0)
|
||||||
|
while (pending.length > 0) {
|
||||||
|
const next: Event[] = []
|
||||||
|
let changed = false
|
||||||
|
for (const event of pending) {
|
||||||
|
if (!tracked(sid(event))) {
|
||||||
|
next.push(event)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = true
|
||||||
|
yield* applyEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
buffered.push(...next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pending = next
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const watch = Effect.fn("RunStreamTransport.watch")(() =>
|
||||||
|
Stream.fromAsyncIterable(events.stream, (error) =>
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
).pipe(
|
||||||
|
Stream.takeUntil(() => input.footer.isClosed || abort.signal.aborted),
|
||||||
|
Stream.runForEach(
|
||||||
|
Effect.fn("RunStreamTransport.event")(function* (item: unknown) {
|
||||||
|
if (input.footer.isClosed) {
|
||||||
|
abort.abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMatchingDisposeEvent(item, input.directory)) {
|
||||||
|
yield* fail(new Error("instance disposed"))
|
||||||
|
yield* closeScope()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = globalPayloadEvent(item)
|
||||||
|
if (!event) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionID = sid(event)
|
||||||
|
if (booting) {
|
||||||
|
if (sessionID) {
|
||||||
|
input.trace?.write("recv.event", event)
|
||||||
|
buffered.push(event)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tracked(sessionID)) {
|
||||||
|
if (sessionID) {
|
||||||
|
input.trace?.write("recv.event", event)
|
||||||
|
buffered.push(event)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input.trace?.write("recv.event", event)
|
||||||
|
yield* applyEvent(event)
|
||||||
|
yield* drainBuffered()
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Effect.catch((error) => (abort.signal.aborted ? Effect.void : fail(error))),
|
Effect.catch((error) => (abort.signal.aborted ? Effect.void : fail(error))),
|
||||||
@@ -823,8 +951,8 @@ function createLayer(input: StreamInput) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
yield* bootstrap()
|
|
||||||
yield* Scope.provide(scope)(watch().pipe(Effect.forkScoped))
|
yield* Scope.provide(scope)(watch().pipe(Effect.forkScoped))
|
||||||
|
yield* bootstrap()
|
||||||
|
|
||||||
const runPromptTurn = Effect.fn("RunStreamTransport.runPromptTurn")(function* (next: SessionTurnInput) {
|
const runPromptTurn = Effect.fn("RunStreamTransport.runPromptTurn")(function* (next: SessionTurnInput) {
|
||||||
if (closed || next.signal?.aborted || input.footer.isClosed) {
|
if (closed || next.signal?.aborted || input.footer.isClosed) {
|
||||||
|
|||||||
@@ -420,11 +420,28 @@ function ensureBlockerTab(
|
|||||||
title: string | undefined,
|
title: string | undefined,
|
||||||
kind: "permission" | "question",
|
kind: "permission" | "question",
|
||||||
) {
|
) {
|
||||||
if (data.tabs.has(sessionID)) {
|
const current = data.tabs.get(sessionID)
|
||||||
|
if (current) {
|
||||||
ensureDetail(data, sessionID)
|
ensureDetail(data, sessionID)
|
||||||
|
if (current.status !== "running") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const next = {
|
||||||
|
...current,
|
||||||
|
description: kind === "permission" ? "Pending permission" : "Pending question",
|
||||||
|
status: "running" as const,
|
||||||
|
title: current.title ?? title,
|
||||||
|
lastUpdatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
if (sameSubagentTab(current, next)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
data.tabs.set(sessionID, next)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
data.tabs.set(sessionID, {
|
data.tabs.set(sessionID, {
|
||||||
sessionID,
|
sessionID,
|
||||||
partID: `bootstrap:${sessionID}`,
|
partID: `bootstrap:${sessionID}`,
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export type RunInput = {
|
|||||||
sessionID: string
|
sessionID: string
|
||||||
sessionTitle?: string
|
sessionTitle?: string
|
||||||
resume?: boolean
|
resume?: boolean
|
||||||
|
replay?: boolean
|
||||||
|
replayLimit?: number
|
||||||
agent: string | undefined
|
agent: string | undefined
|
||||||
model: PromptModel | undefined
|
model: PromptModel | undefined
|
||||||
variant: string | undefined
|
variant: string | undefined
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { createMemo, createResource } from "solid-js"
|
import { createMemo, createResource } from "solid-js"
|
||||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||||
import { useDialog } from "@tui/ui/dialog"
|
import { useDialog } from "@tui/ui/dialog"
|
||||||
|
import { useProject } from "@tui/context/project"
|
||||||
import { useSDK } from "@tui/context/sdk"
|
import { useSDK } from "@tui/context/sdk"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
|
||||||
export function DialogTag(props: { onSelect?: (value: string) => void }) {
|
export function DialogTag(props: { onSelect?: (value: string) => void }) {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const project = useProject()
|
||||||
|
|
||||||
const [store] = createStore({
|
const [store] = createStore({
|
||||||
filter: "",
|
filter: "",
|
||||||
@@ -17,6 +19,7 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) {
|
|||||||
async () => {
|
async () => {
|
||||||
const result = await sdk.client.find.files({
|
const result = await sdk.client.find.files({
|
||||||
query: store.filter,
|
query: store.filter,
|
||||||
|
workspace: project.workspace.current(),
|
||||||
})
|
})
|
||||||
if (result.error) return []
|
if (result.error) return []
|
||||||
const sliced = (result.data ?? []).slice(0, 5)
|
const sliced = (result.data ?? []).slice(0, 5)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { firstBy } from "remeda"
|
|||||||
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
|
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useEditorContext } from "@tui/context/editor"
|
import { useEditorContext } from "@tui/context/editor"
|
||||||
|
import { useProject } from "@tui/context/project"
|
||||||
import { useSDK } from "@tui/context/sdk"
|
import { useSDK } from "@tui/context/sdk"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { getScrollAcceleration } from "../../util/scroll"
|
import { getScrollAcceleration } from "../../util/scroll"
|
||||||
@@ -19,7 +20,7 @@ import type { PromptInfo } from "./history"
|
|||||||
import { useFrecency } from "./frecency"
|
import { useFrecency } from "./frecency"
|
||||||
import { useBindings } from "../../keymap"
|
import { useBindings } from "../../keymap"
|
||||||
import { Reference } from "@/reference/reference"
|
import { Reference } from "@/reference/reference"
|
||||||
import type { Config } from "@/config/config"
|
import { ConfigReference } from "@/config/reference"
|
||||||
import { displayCharAt, mentionTriggerIndex } from "@/cli/cmd/prompt-display"
|
import { displayCharAt, mentionTriggerIndex } from "@/cli/cmd/prompt-display"
|
||||||
|
|
||||||
function removeLineRange(input: string) {
|
function removeLineRange(input: string) {
|
||||||
@@ -85,6 +86,7 @@ export function Autocomplete(props: {
|
|||||||
const editor = useEditorContext()
|
const editor = useEditorContext()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const project = useProject()
|
||||||
const command = useCommandPalette()
|
const command = useCommandPalette()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
@@ -310,7 +312,7 @@ export function Autocomplete(props: {
|
|||||||
`Referenced configured reference @${reference.name}.`,
|
`Referenced configured reference @${reference.name}.`,
|
||||||
...(reference.kind === "local" ? ["Kind: local directory"] : []),
|
...(reference.kind === "local" ? ["Kind: local directory"] : []),
|
||||||
...(reference.kind === "git" ? ["Kind: git repository"] : []),
|
...(reference.kind === "git" ? ["Kind: git repository"] : []),
|
||||||
...(reference.kind === "invalid" ? [`Repository: ${reference.repository}`] : []),
|
...(reference.kind === "invalid" && reference.repository ? [`Repository: ${reference.repository}`] : []),
|
||||||
...(reference.kind === "git" ? [`Repository: ${reference.repository}`] : []),
|
...(reference.kind === "git" ? [`Repository: ${reference.repository}`] : []),
|
||||||
...(reference.kind === "git" && reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
|
...(reference.kind === "git" && reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
|
||||||
...(reference.kind === "invalid" ? [] : [`Reference root: ${reference.path}`]),
|
...(reference.kind === "invalid" ? [] : [`Reference root: ${reference.path}`]),
|
||||||
@@ -324,7 +326,7 @@ export function Autocomplete(props: {
|
|||||||
|
|
||||||
const references = createMemo(() =>
|
const references = createMemo(() =>
|
||||||
Reference.resolveAll({
|
Reference.resolveAll({
|
||||||
references: (sync.data.config.reference ?? {}) as NonNullable<Config.Info["reference"]>,
|
references: ConfigReference.normalize(sync.data.config.reference ?? {}),
|
||||||
directory: sync.path.directory || process.cwd(),
|
directory: sync.path.directory || process.cwd(),
|
||||||
worktree: sync.path.worktree || sync.path.directory || process.cwd(),
|
worktree: sync.path.worktree || sync.path.directory || process.cwd(),
|
||||||
}),
|
}),
|
||||||
@@ -382,6 +384,7 @@ export function Autocomplete(props: {
|
|||||||
// Get files from SDK
|
// Get files from SDK
|
||||||
const result = await sdk.client.find.files({
|
const result = await sdk.client.find.files({
|
||||||
query: baseQuery,
|
query: baseQuery,
|
||||||
|
workspace: project.workspace.current(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const options: AutocompleteOption[] = []
|
const options: AutocompleteOption[] = []
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { MessageID, PartID } from "@/session/schema"
|
|||||||
import { createStore, produce, unwrap } from "solid-js/store"
|
import { createStore, produce, unwrap } from "solid-js/store"
|
||||||
import { usePromptHistory, type PromptInfo } from "./history"
|
import { usePromptHistory, type PromptInfo } from "./history"
|
||||||
import { computePromptTraits } from "./traits"
|
import { computePromptTraits } from "./traits"
|
||||||
import { assign } from "./part"
|
import { assign, expandPastedTextPlaceholders } from "./part"
|
||||||
import { usePromptStash } from "./stash"
|
import { usePromptStash } from "./stash"
|
||||||
import { DialogStash } from "../dialog-stash"
|
import { DialogStash } from "../dialog-stash"
|
||||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||||
@@ -1544,6 +1544,9 @@ export function Prompt(props: PromptProps) {
|
|||||||
}}
|
}}
|
||||||
ref={(r: TextareaRenderable) => {
|
ref={(r: TextareaRenderable) => {
|
||||||
input = r
|
input = r
|
||||||
|
Object.assign(r, {
|
||||||
|
getClipboardText: (text: string) => expandPastedTextPlaceholders(text, store.prompt.parts),
|
||||||
|
})
|
||||||
setInputTarget(r)
|
setInputTarget(r)
|
||||||
if (promptPartTypeId === 0) {
|
if (promptPartTypeId === 0) {
|
||||||
promptPartTypeId = input.extmarks.registerType("prompt-part")
|
promptPartTypeId = input.extmarks.registerType("prompt-part")
|
||||||
|
|||||||
@@ -14,3 +14,10 @@ export function assign(part: Item): Item & { id: PartID } {
|
|||||||
id: PartID.ascending(),
|
id: PartID.ascending(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function expandPastedTextPlaceholders(text: string, parts: PromptInfo["parts"]) {
|
||||||
|
return parts.reduce((result, part) => {
|
||||||
|
if (part.type !== "text" || !part.source?.text) return result
|
||||||
|
return result.replace(part.source.text.value, part.text)
|
||||||
|
}, text)
|
||||||
|
}
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ type RuntimeState = {
|
|||||||
plugins: PluginEntry[]
|
plugins: PluginEntry[]
|
||||||
plugins_by_id: Map<string, PluginEntry>
|
plugins_by_id: Map<string, PluginEntry>
|
||||||
pending: Map<string, ConfigPlugin.Origin>
|
pending: Map<string, ConfigPlugin.Origin>
|
||||||
|
dispose_timeout_ms: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const log = Log.create({ service: "tui.plugin" })
|
const log = Log.create({ service: "tui.plugin" })
|
||||||
@@ -394,7 +395,7 @@ async function syncPluginThemes(plugin: PluginEntry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPluginScope(load: PluginLoad, id: string) {
|
function createPluginScope(load: PluginLoad, id: string, disposeTimeoutMs: number) {
|
||||||
const ctrl = new AbortController()
|
const ctrl = new AbortController()
|
||||||
let list: { key: symbol; fn: TuiDispose }[] = []
|
let list: { key: symbol; fn: TuiDispose }[] = []
|
||||||
let done = false
|
let done = false
|
||||||
@@ -436,14 +437,14 @@ function createPluginScope(load: PluginLoad, id: string) {
|
|||||||
ctrl.abort()
|
ctrl.abort()
|
||||||
const queue = [...list].reverse()
|
const queue = [...list].reverse()
|
||||||
list = []
|
list = []
|
||||||
const until = Date.now() + DISPOSE_TIMEOUT_MS
|
const until = Date.now() + disposeTimeoutMs
|
||||||
for (const item of queue) {
|
for (const item of queue) {
|
||||||
const left = until - Date.now()
|
const left = until - Date.now()
|
||||||
if (left <= 0) {
|
if (left <= 0) {
|
||||||
fail("timed out cleaning up tui plugin", {
|
fail("timed out cleaning up tui plugin", {
|
||||||
path: load.spec,
|
path: load.spec,
|
||||||
id,
|
id,
|
||||||
timeout: DISPOSE_TIMEOUT_MS,
|
timeout: disposeTimeoutMs,
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -454,7 +455,7 @@ function createPluginScope(load: PluginLoad, id: string) {
|
|||||||
fail("timed out cleaning up tui plugin", {
|
fail("timed out cleaning up tui plugin", {
|
||||||
path: load.spec,
|
path: load.spec,
|
||||||
id,
|
id,
|
||||||
timeout: DISPOSE_TIMEOUT_MS,
|
timeout: disposeTimeoutMs,
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -523,7 +524,7 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per
|
|||||||
if (persist) writePluginEnabledState(state.api, plugin.id, true)
|
if (persist) writePluginEnabledState(state.api, plugin.id, true)
|
||||||
if (plugin.scope) return true
|
if (plugin.scope) return true
|
||||||
|
|
||||||
const scope = createPluginScope(plugin.load, plugin.id)
|
const scope = createPluginScope(plugin.load, plugin.id, state.dispose_timeout_ms)
|
||||||
const api = pluginApi(state, plugin, scope, plugin.id)
|
const api = pluginApi(state, plugin, scope, plugin.id)
|
||||||
const ok = await Promise.resolve()
|
const ok = await Promise.resolve()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -1002,7 +1003,12 @@ let loaded: Promise<void> | undefined
|
|||||||
let runtime: RuntimeState | undefined
|
let runtime: RuntimeState | undefined
|
||||||
export const Slot = View
|
export const Slot = View
|
||||||
|
|
||||||
export async function init(input: { api: HostPluginApi; config: TuiConfig.Resolved; dispose?: () => void }) {
|
export async function init(input: {
|
||||||
|
api: HostPluginApi
|
||||||
|
config: TuiConfig.Resolved
|
||||||
|
dispose?: () => void
|
||||||
|
disposeTimeoutMs?: number
|
||||||
|
}) {
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
if (dir !== cwd) {
|
if (dir !== cwd) {
|
||||||
@@ -1052,7 +1058,7 @@ export async function dispose() {
|
|||||||
state.dispose?.()
|
state.dispose?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: () => void }) {
|
async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: () => void; disposeTimeoutMs?: number }) {
|
||||||
const { api, config } = input
|
const { api, config } = input
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
const slots = setupSlots(api)
|
const slots = setupSlots(api)
|
||||||
@@ -1064,6 +1070,7 @@ async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: ()
|
|||||||
plugins: [],
|
plugins: [],
|
||||||
plugins_by_id: new Map(),
|
plugins_by_id: new Map(),
|
||||||
pending: new Map(),
|
pending: new Map(),
|
||||||
|
dispose_timeout_ms: input.disposeTimeoutMs ?? DISPOSE_TIMEOUT_MS,
|
||||||
}
|
}
|
||||||
runtime = next
|
runtime = next
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ export function collapseToolOutput(output: string, maxLines: number, maxChars: n
|
|||||||
|
|
||||||
const preview = lines.slice(0, maxLines).join("\n")
|
const preview = lines.slice(0, maxLines).join("\n")
|
||||||
if (Array.from(preview).length > maxChars) {
|
if (Array.from(preview).length > maxChars) {
|
||||||
return { output: Array.from(preview).slice(0, Math.max(0, maxChars - 1)).join("") + "…", overflow: true }
|
return {
|
||||||
|
output:
|
||||||
|
Array.from(preview)
|
||||||
|
.slice(0, Math.max(0, maxChars - 1))
|
||||||
|
.join("") + "…",
|
||||||
|
overflow: true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { output: [...lines.slice(0, maxLines), "…"].join("\n"), overflow: true }
|
return { output: [...lines.slice(0, maxLines), "…"].join("\n"), overflow: true }
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ type Toast = {
|
|||||||
|
|
||||||
type FocusableSelectionTarget = {
|
type FocusableSelectionTarget = {
|
||||||
hasSelection: () => boolean
|
hasSelection: () => boolean
|
||||||
|
getClipboardText?: (text: string) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Renderer = {
|
type Renderer = {
|
||||||
@@ -23,10 +24,17 @@ type SelectionKeyEvent = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function copy(renderer: Renderer, toast: Toast): boolean {
|
export function copy(renderer: Renderer, toast: Toast): boolean {
|
||||||
const text = renderer.getSelection()?.getSelectedText()
|
const selection = renderer.getSelection()
|
||||||
|
if (!selection) return false
|
||||||
|
|
||||||
|
const text = selection.getSelectedText()
|
||||||
if (!text) return false
|
if (!text) return false
|
||||||
|
|
||||||
Clipboard.copy(text)
|
const focus = renderer.currentFocusedRenderable
|
||||||
|
const clipboardText =
|
||||||
|
focus?.getClipboardText && selection.selectedRenderables.includes(focus) ? focus.getClipboardText(text) : text
|
||||||
|
|
||||||
|
Clipboard.copy(clipboardText)
|
||||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||||
.catch(toast.error)
|
.catch(toast.error)
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,52 @@ const Local = Schema.Struct({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const Entry = Schema.Union([Schema.String, Git, Local]).annotate({ identifier: "ReferenceConfigEntry" })
|
export const Entry = Schema.Union([Schema.String, Git, Local]).annotate({ identifier: "ReferenceConfigEntry" })
|
||||||
|
export type Entry = Schema.Schema.Type<typeof Entry>
|
||||||
|
|
||||||
export const Info = Schema.Record(Schema.String, Entry).annotate({ identifier: "ReferenceConfig" })
|
export const Info = Schema.Record(Schema.String, Entry).annotate({ identifier: "ReferenceConfig" })
|
||||||
export type Info = Schema.Schema.Type<typeof Info>
|
export type Info = Schema.Schema.Type<typeof Info>
|
||||||
|
|
||||||
|
export type NormalizedEntry =
|
||||||
|
| {
|
||||||
|
kind: "local"
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "git"
|
||||||
|
repository: string
|
||||||
|
branch?: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "invalid"
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NormalizedInfo = Record<string, NormalizedEntry>
|
||||||
|
|
||||||
|
export function validateAlias(name: string) {
|
||||||
|
if (name.length === 0) return "Reference alias must not be empty"
|
||||||
|
if (/[\/\s`,]/.test(name)) {
|
||||||
|
return "Reference alias must not contain /, whitespace, comma, or backtick"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEntry(entry: Entry): NormalizedEntry {
|
||||||
|
if (typeof entry === "string") {
|
||||||
|
if (entry.startsWith(".") || entry.startsWith("/") || entry.startsWith("~")) {
|
||||||
|
return { kind: "local", path: entry }
|
||||||
|
}
|
||||||
|
return { kind: "git", repository: entry }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("path" in entry) return { kind: "local", path: entry.path }
|
||||||
|
return { kind: "git", repository: entry.repository, branch: entry.branch }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalize(info: Info): NormalizedInfo {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(info).map(([name, entry]) => {
|
||||||
|
const aliasError = validateAlias(name)
|
||||||
|
return [name, aliasError ? { kind: "invalid" as const, message: aliasError } : normalizeEntry(entry)] as const
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ export interface Interface {
|
|||||||
workspaceID: WorkspaceID,
|
workspaceID: WorkspaceID,
|
||||||
state: Record<string, number>,
|
state: Record<string, number>,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
timeout?: number,
|
||||||
) => Effect.Effect<void, WaitForSyncError>
|
) => Effect.Effect<void, WaitForSyncError>
|
||||||
readonly startWorkspaceSyncing: (projectID: ProjectID) => Effect.Effect<void>
|
readonly startWorkspaceSyncing: (projectID: ProjectID) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
@@ -946,12 +947,13 @@ export const layer = Layer.effect(
|
|||||||
workspaceID: WorkspaceID,
|
workspaceID: WorkspaceID,
|
||||||
state: Record<string, number>,
|
state: Record<string, number>,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
timeout = TIMEOUT,
|
||||||
) {
|
) {
|
||||||
if (synced(state)) return
|
if (synced(state)) return
|
||||||
|
|
||||||
yield* Effect.catch(
|
yield* Effect.catch(
|
||||||
waitEvent({
|
waitEvent({
|
||||||
timeout: TIMEOUT,
|
timeout,
|
||||||
signal,
|
signal,
|
||||||
fn(event) {
|
fn(event) {
|
||||||
if (event.workspace !== workspaceID && event.payload.type !== "sync") {
|
if (event.workspace !== workspaceID && event.payload.type !== "sync") {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
|
|||||||
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
|
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
|
||||||
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
|
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
|
||||||
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
|
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
|
||||||
|
experimentalNativeLlm: enabledByExperimental("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
|
||||||
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
|
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ export const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to bus events, fiber interrupted when scope closes
|
// Subscribe to bus events, fiber interrupted when scope closes
|
||||||
yield* bus.subscribeAll().pipe(
|
yield* (yield* bus.subscribeAll()).pipe(
|
||||||
Stream.runForEach((input) =>
|
Stream.runForEach((input) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
for (const hook of hooks) {
|
for (const hook of hooks) {
|
||||||
|
|||||||
@@ -425,7 +425,7 @@ export const layer: Layer.Layer<
|
|||||||
|
|
||||||
const initState = yield* InstanceState.make(
|
const initState = yield* InstanceState.make(
|
||||||
Effect.fn("Project.initState")(function* (ctx) {
|
Effect.fn("Project.initState")(function* (ctx) {
|
||||||
yield* bus.subscribe(Command.Event.Executed).pipe(
|
yield* (yield* bus.subscribe(Command.Event.Executed)).pipe(
|
||||||
Stream.runForEach((payload) =>
|
Stream.runForEach((payload) =>
|
||||||
payload.properties.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void,
|
payload.properties.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
|
|||||||
const value = { current, root }
|
const value = { current, root }
|
||||||
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
|
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
|
||||||
|
|
||||||
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
|
yield* (yield* bus.subscribe(FileWatcher.Event.Updated)).pipe(
|
||||||
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
|
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
|
||||||
Stream.runForEach((_evt) =>
|
Stream.runForEach((_evt) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -3,14 +3,12 @@ import { Effect, Context, Layer, Scope } from "effect"
|
|||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
|
import { ConfigReference } from "@/config/reference"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { Git } from "@/git"
|
import { parseRepositoryReference, repositoryCachePath, type RemoteReference } from "@/util/repository"
|
||||||
import { parseRepositoryReference, repositoryCachePath, type Reference as RepositoryReference } from "@/util/repository"
|
|
||||||
import { RepositoryCache } from "./repository-cache"
|
import { RepositoryCache } from "./repository-cache"
|
||||||
|
|
||||||
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
|
|
||||||
|
|
||||||
export type Resolved =
|
export type Resolved =
|
||||||
| {
|
| {
|
||||||
name: string
|
name: string
|
||||||
@@ -21,23 +19,25 @@ export type Resolved =
|
|||||||
name: string
|
name: string
|
||||||
kind: "git"
|
kind: "git"
|
||||||
repository: string
|
repository: string
|
||||||
reference: RepositoryReference
|
reference: RemoteReference
|
||||||
path: string
|
path: string
|
||||||
branch?: string
|
branch?: string
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
name: string
|
name: string
|
||||||
kind: "invalid"
|
kind: "invalid"
|
||||||
repository: string
|
repository?: string
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
references: Resolved[]
|
references: Resolved[]
|
||||||
materializeAll: Effect.Effect<void>
|
materializeAll: Effect.Effect<void>
|
||||||
materializeByPath: { path: string; run: Effect.Effect<void> }[]
|
materializeByPath: Materializer[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Materializer = { path: string; run: Effect.Effect<void> }
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly init: () => Effect.Effect<void>
|
readonly init: () => Effect.Effect<void>
|
||||||
readonly list: () => Effect.Effect<Resolved[]>
|
readonly list: () => Effect.Effect<Resolved[]>
|
||||||
@@ -90,31 +90,75 @@ function containsReferencePath(referencePath: string, target: string) {
|
|||||||
return AppFileSystem.contains(normalizedTarget(referencePath) ?? referencePath, target)
|
return AppFileSystem.contains(normalizedTarget(referencePath) ?? referencePath, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uniqueGitReferences(references: Resolved[]) {
|
||||||
|
const seenPath = new Set<string>()
|
||||||
|
return references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
|
||||||
|
if (reference.kind !== "git") return false
|
||||||
|
if (seenPath.has(reference.path)) return false
|
||||||
|
seenPath.add(reference.path)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function materializeReference(cache: RepositoryCache.Interface, reference: Extract<Resolved, { kind: "git" }>) {
|
||||||
|
return cache.ensure({ reference: reference.reference, branch: reference.branch, refresh: true }).pipe(
|
||||||
|
Effect.asVoid,
|
||||||
|
Effect.catchCause((cause) =>
|
||||||
|
Effect.logWarning("failed to materialize reference repository").pipe(
|
||||||
|
Effect.annotateLogs({ name: reference.name, cause }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const materializers = Effect.fn("Reference.materializers")(function* (
|
||||||
|
cache: RepositoryCache.Interface,
|
||||||
|
references: Resolved[],
|
||||||
|
) {
|
||||||
|
return yield* Effect.forEach(
|
||||||
|
uniqueGitReferences(references),
|
||||||
|
Effect.fnUntraced(function* (reference) {
|
||||||
|
return { path: reference.path, run: yield* Effect.cached(materializeReference(cache, reference)) }
|
||||||
|
}),
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function materializeAll(input: { flags: RuntimeFlags.Info; materializers: Materializer[] }) {
|
||||||
|
if (!input.flags.experimentalScout) return Effect.void
|
||||||
|
return Effect.forEach(
|
||||||
|
input.materializers,
|
||||||
|
Effect.fnUntraced(function* (item) {
|
||||||
|
yield* item.run
|
||||||
|
}),
|
||||||
|
{ concurrency: 4, discard: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function materializeByPath(materializers: Materializer[], target: string) {
|
||||||
|
return materializers.find((item) => containsReferencePath(item.path, target))?.run ?? Effect.void
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsGitReferencePath(references: Resolved[], target: string) {
|
||||||
|
return references.some((reference) => reference.kind === "git" && containsReferencePath(reference.path, target))
|
||||||
|
}
|
||||||
|
|
||||||
export function resolve(input: {
|
export function resolve(input: {
|
||||||
name: string
|
name: string
|
||||||
reference: ReferenceEntry
|
reference: ConfigReference.NormalizedEntry
|
||||||
directory: string
|
directory: string
|
||||||
worktree: string
|
worktree: string
|
||||||
}): Resolved {
|
}): Resolved {
|
||||||
if (typeof input.reference === "string") {
|
if (input.reference.kind === "invalid") {
|
||||||
if (input.reference.startsWith(".") || input.reference.startsWith("/") || input.reference.startsWith("~")) {
|
return { name: input.name, kind: "invalid", message: input.reference.message }
|
||||||
return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference }) }
|
|
||||||
}
|
}
|
||||||
return resolveGit({ name: input.name, repository: input.reference })
|
if (input.reference.kind === "local") {
|
||||||
}
|
|
||||||
|
|
||||||
if ("path" in input.reference) {
|
|
||||||
return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference.path }) }
|
return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference.path }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolveGit({ name: input.name, repository: input.reference.repository, branch: input.reference.branch })
|
return resolveGit({ name: input.name, repository: input.reference.repository, branch: input.reference.branch })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveAll(input: {
|
export function resolveAll(input: { references: ConfigReference.NormalizedInfo; directory: string; worktree: string }) {
|
||||||
references: NonNullable<Config.Info["reference"]>
|
|
||||||
directory: string
|
|
||||||
worktree: string
|
|
||||||
}) {
|
|
||||||
const seen = new Map<string, { name: string; branch?: string }>()
|
const seen = new Map<string, { name: string; branch?: string }>()
|
||||||
return Object.entries(input.references).map(([name, reference]) => {
|
return Object.entries(input.references).map(([name, reference]) => {
|
||||||
const resolved = resolve({ name, reference, directory: input.directory, worktree: input.worktree })
|
const resolved = resolve({ name, reference, directory: input.directory, worktree: input.worktree })
|
||||||
@@ -140,8 +184,7 @@ export const layer = Layer.effect(
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const fs = yield* AppFileSystem.Service
|
const cache = yield* RepositoryCache.Service
|
||||||
const git = yield* Git.Service
|
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
const flags = yield* RuntimeFlags.Service
|
const flags = yield* RuntimeFlags.Service
|
||||||
|
|
||||||
@@ -149,53 +192,14 @@ export const layer = Layer.effect(
|
|||||||
Effect.fn("Reference.state")(function* (ctx) {
|
Effect.fn("Reference.state")(function* (ctx) {
|
||||||
const cfg = yield* config.get()
|
const cfg = yield* config.get()
|
||||||
const references = resolveAll({
|
const references = resolveAll({
|
||||||
references: cfg.reference ?? {},
|
references: ConfigReference.normalize(cfg.reference ?? {}),
|
||||||
directory: ctx.directory,
|
directory: ctx.directory,
|
||||||
worktree: ctx.worktree,
|
worktree: ctx.worktree,
|
||||||
})
|
})
|
||||||
const seenPath = new Set<string>()
|
const materializeByPath = yield* materializers(cache, references)
|
||||||
const gitReferences = references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
|
const materializeAllCached = yield* Effect.cached(materializeAll({ flags, materializers: materializeByPath }))
|
||||||
if (reference.kind !== "git") return false
|
|
||||||
if (seenPath.has(reference.path)) return false
|
|
||||||
seenPath.add(reference.path)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
const materializeByPath = yield* Effect.forEach(
|
|
||||||
gitReferences,
|
|
||||||
Effect.fnUntraced(function* (reference) {
|
|
||||||
const run = yield* Effect.cached(
|
|
||||||
RepositoryCache.ensure(
|
|
||||||
{ reference: reference.reference, branch: reference.branch, refresh: true },
|
|
||||||
{ fs, git },
|
|
||||||
).pipe(
|
|
||||||
Effect.asVoid,
|
|
||||||
Effect.catchCause((cause) =>
|
|
||||||
Effect.logWarning("failed to materialize reference repository").pipe(
|
|
||||||
Effect.annotateLogs({ name: reference.name, cause }),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return { path: reference.path, run }
|
|
||||||
}),
|
|
||||||
{ concurrency: "unbounded" },
|
|
||||||
)
|
|
||||||
|
|
||||||
const materializeAll = yield* Effect.cached(
|
return { references, materializeAll: materializeAllCached, materializeByPath }
|
||||||
flags.experimentalScout
|
|
||||||
? Effect.gen(function* () {
|
|
||||||
yield* Effect.forEach(
|
|
||||||
materializeByPath,
|
|
||||||
Effect.fnUntraced(function* (item) {
|
|
||||||
yield* item.run
|
|
||||||
}),
|
|
||||||
{ concurrency: 4, discard: true },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
: Effect.void,
|
|
||||||
)
|
|
||||||
|
|
||||||
return { references, materializeAll, materializeByPath }
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -214,18 +218,13 @@ export const layer = Layer.effect(
|
|||||||
if (!flags.experimentalScout) return
|
if (!flags.experimentalScout) return
|
||||||
const full = normalizedTarget(target)
|
const full = normalizedTarget(target)
|
||||||
if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll)
|
if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll)
|
||||||
return yield* InstanceState.useEffect(
|
return yield* InstanceState.useEffect(state, (s) => materializeByPath(s.materializeByPath, full))
|
||||||
state,
|
|
||||||
(s) => s.materializeByPath.find((item) => containsReferencePath(item.path, full))?.run ?? Effect.void,
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
contains: Effect.fn("Reference.contains")(function* (target?: string) {
|
contains: Effect.fn("Reference.contains")(function* (target?: string) {
|
||||||
if (!flags.experimentalScout) return false
|
if (!flags.experimentalScout) return false
|
||||||
const full = normalizedTarget(target)
|
const full = normalizedTarget(target)
|
||||||
if (!full) return false
|
if (!full) return false
|
||||||
return yield* InstanceState.use(state, (s) =>
|
return yield* InstanceState.use(state, (s) => containsGitReferencePath(s.references, full))
|
||||||
s.references.some((reference) => reference.kind === "git" && containsReferencePath(reference.path, full)),
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
@@ -233,8 +232,7 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(RepositoryCache.defaultLayer),
|
||||||
Layer.provide(Git.defaultLayer),
|
|
||||||
Layer.provide(RuntimeFlags.defaultLayer),
|
Layer.provide(RuntimeFlags.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Flock } from "@opencode-ai/core/util/flock"
|
import { Flock } from "@opencode-ai/core/util/flock"
|
||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
sameRepositoryReference,
|
sameRepositoryReference,
|
||||||
parseRepositoryReference,
|
parseRepositoryReference,
|
||||||
validateRepositoryBranch,
|
validateRepositoryBranch,
|
||||||
type Reference as RepositoryReference,
|
isRemoteRepositoryReference,
|
||||||
|
type RemoteReference,
|
||||||
} from "@/util/repository"
|
} from "@/util/repository"
|
||||||
|
|
||||||
export type Result = {
|
export type Result = {
|
||||||
@@ -21,6 +22,82 @@ export type Result = {
|
|||||||
branch?: string
|
branch?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EnsureInput = {
|
||||||
|
reference: RemoteReference
|
||||||
|
refresh?: boolean
|
||||||
|
branch?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidRepositoryError extends Schema.TaggedErrorClass<InvalidRepositoryError>()(
|
||||||
|
"RepositoryCacheInvalidRepositoryError",
|
||||||
|
{
|
||||||
|
repository: Schema.String,
|
||||||
|
message: Schema.String,
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
export class InvalidBranchError extends Schema.TaggedErrorClass<InvalidBranchError>()(
|
||||||
|
"RepositoryCacheInvalidBranchError",
|
||||||
|
{
|
||||||
|
branch: Schema.String,
|
||||||
|
message: Schema.String,
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
export class CloneFailedError extends Schema.TaggedErrorClass<CloneFailedError>()("RepositoryCacheCloneFailedError", {
|
||||||
|
repository: Schema.String,
|
||||||
|
message: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class FetchFailedError extends Schema.TaggedErrorClass<FetchFailedError>()("RepositoryCacheFetchFailedError", {
|
||||||
|
repository: Schema.String,
|
||||||
|
message: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class CheckoutFailedError extends Schema.TaggedErrorClass<CheckoutFailedError>()(
|
||||||
|
"RepositoryCacheCheckoutFailedError",
|
||||||
|
{
|
||||||
|
repository: Schema.String,
|
||||||
|
branch: Schema.String,
|
||||||
|
message: Schema.String,
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
export class ResetFailedError extends Schema.TaggedErrorClass<ResetFailedError>()("RepositoryCacheResetFailedError", {
|
||||||
|
repository: Schema.String,
|
||||||
|
message: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class LockFailedError extends Schema.TaggedErrorClass<LockFailedError>()("RepositoryCacheLockFailedError", {
|
||||||
|
localPath: Schema.String,
|
||||||
|
message: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class CacheOperationError extends Schema.TaggedErrorClass<CacheOperationError>()(
|
||||||
|
"RepositoryCacheOperationError",
|
||||||
|
{
|
||||||
|
operation: Schema.String,
|
||||||
|
path: Schema.String,
|
||||||
|
message: Schema.String,
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
export type Error =
|
||||||
|
| InvalidRepositoryError
|
||||||
|
| InvalidBranchError
|
||||||
|
| CloneFailedError
|
||||||
|
| FetchFailedError
|
||||||
|
| CheckoutFailedError
|
||||||
|
| ResetFailedError
|
||||||
|
| LockFailedError
|
||||||
|
| CacheOperationError
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
ensure: (input: EnsureInput) => Effect.Effect<Result, Error>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/RepositoryCache") {}
|
||||||
|
|
||||||
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
|
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
|
||||||
if (!input.reuse) return "cloned" as const
|
if (!input.reuse) return "cloned" as const
|
||||||
if (input.branchMatches === false) return "refreshed" as const
|
if (input.branchMatches === false) return "refreshed" as const
|
||||||
@@ -43,18 +120,53 @@ function resetTarget(input: {
|
|||||||
return "HEAD"
|
return "HEAD"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
|
function errorMessage(error: unknown) {
|
||||||
input: {
|
return error instanceof globalThis.Error ? error.message : String(error)
|
||||||
reference: RepositoryReference
|
}
|
||||||
refresh?: boolean
|
|
||||||
branch?: string
|
export function isError(error: unknown): error is Error {
|
||||||
},
|
return (
|
||||||
|
error instanceof InvalidRepositoryError ||
|
||||||
|
error instanceof InvalidBranchError ||
|
||||||
|
error instanceof CloneFailedError ||
|
||||||
|
error instanceof FetchFailedError ||
|
||||||
|
error instanceof CheckoutFailedError ||
|
||||||
|
error instanceof ResetFailedError ||
|
||||||
|
error instanceof LockFailedError ||
|
||||||
|
error instanceof CacheOperationError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseRemoteReference = Effect.fn("RepositoryCache.parseRemoteReference")(function* (repository: string) {
|
||||||
|
const reference = parseRepositoryReference(repository)
|
||||||
|
if (!reference) {
|
||||||
|
return yield* new InvalidRepositoryError({
|
||||||
|
repository,
|
||||||
|
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!isRemoteRepositoryReference(reference)) {
|
||||||
|
return yield* new InvalidRepositoryError({ repository, message: "Local file repositories are not supported" })
|
||||||
|
}
|
||||||
|
return reference
|
||||||
|
})
|
||||||
|
|
||||||
|
export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(function* (branch: string) {
|
||||||
|
try {
|
||||||
|
validateRepositoryBranch(branch)
|
||||||
|
} catch (error) {
|
||||||
|
return yield* new InvalidBranchError({ branch, message: errorMessage(error) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const ensureWithServices = Effect.fn("RepositoryCache.ensureWithServices")(function* (
|
||||||
|
input: EnsureInput,
|
||||||
services: {
|
services: {
|
||||||
fs: AppFileSystem.Interface
|
fs: AppFileSystem.Interface
|
||||||
git: Git.Interface
|
git: Git.Interface
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (input.branch) validateRepositoryBranch(input.branch)
|
if (input.branch) yield* validateBranch(input.branch)
|
||||||
|
|
||||||
const repository = input.reference.label
|
const repository = input.reference.label
|
||||||
const remote = input.reference.remote
|
const remote = input.reference.remote
|
||||||
@@ -62,10 +174,24 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
|
|||||||
const cloneTarget = parseRepositoryReference(remote) ?? input.reference
|
const cloneTarget = parseRepositoryReference(remote) ?? input.reference
|
||||||
|
|
||||||
return yield* Effect.acquireUseRelease(
|
return yield* Effect.acquireUseRelease(
|
||||||
Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })),
|
Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })).pipe(
|
||||||
|
Effect.catch((error: unknown) =>
|
||||||
|
Effect.fail(new LockFailedError({ localPath, message: errorMessage(error) || `Failed to lock ${localPath}` })),
|
||||||
|
),
|
||||||
|
),
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* services.fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie)
|
yield* services.fs.ensureDir(path.dirname(localPath)).pipe(
|
||||||
|
Effect.catch((error: unknown) =>
|
||||||
|
Effect.fail(
|
||||||
|
new CacheOperationError({
|
||||||
|
operation: "ensure cache directory",
|
||||||
|
path: localPath,
|
||||||
|
message: errorMessage(error),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const exists = yield* services.fs.existsSafe(localPath)
|
const exists = yield* services.fs.existsSafe(localPath)
|
||||||
const hasGitDir = yield* services.fs.existsSafe(path.join(localPath, ".git"))
|
const hasGitDir = yield* services.fs.existsSafe(path.join(localPath, ".git"))
|
||||||
@@ -75,7 +201,17 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
|
|||||||
const originReference = origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined
|
const originReference = origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined
|
||||||
const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget))
|
const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget))
|
||||||
if (exists && !reuse) {
|
if (exists && !reuse) {
|
||||||
yield* services.fs.remove(localPath, { recursive: true }).pipe(Effect.orDie)
|
yield* services.fs.remove(localPath, { recursive: true }).pipe(
|
||||||
|
Effect.catch((error: unknown) =>
|
||||||
|
Effect.fail(
|
||||||
|
new CacheOperationError({
|
||||||
|
operation: "remove stale cache",
|
||||||
|
path: localPath,
|
||||||
|
message: errorMessage(error),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentBranch = hasGitDir ? yield* services.git.branch(localPath) : undefined
|
const currentBranch = hasGitDir ? yield* services.git.branch(localPath) : undefined
|
||||||
@@ -91,14 +227,20 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
|
|||||||
{ cwd: path.dirname(localPath) },
|
{ cwd: path.dirname(localPath) },
|
||||||
)
|
)
|
||||||
if (clone.exitCode !== 0) {
|
if (clone.exitCode !== 0) {
|
||||||
throw new Error(clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`)
|
return yield* new CloneFailedError({
|
||||||
|
repository,
|
||||||
|
message: clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "refreshed") {
|
if (status === "refreshed") {
|
||||||
const fetch = yield* services.git.run(["fetch", "--all", "--prune"], { cwd: localPath })
|
const fetch = yield* services.git.run(["fetch", "--all", "--prune"], { cwd: localPath })
|
||||||
if (fetch.exitCode !== 0) {
|
if (fetch.exitCode !== 0) {
|
||||||
throw new Error(fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`)
|
return yield* new FetchFailedError({
|
||||||
|
repository,
|
||||||
|
message: fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.branch) {
|
if (input.branch) {
|
||||||
@@ -106,9 +248,12 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
|
|||||||
cwd: localPath,
|
cwd: localPath,
|
||||||
})
|
})
|
||||||
if (checkout.exitCode !== 0) {
|
if (checkout.exitCode !== 0) {
|
||||||
throw new Error(
|
return yield* new CheckoutFailedError({
|
||||||
|
repository,
|
||||||
|
branch: input.branch,
|
||||||
|
message:
|
||||||
checkout.stderr.toString().trim() || checkout.text().trim() || `Failed to checkout ${input.branch}`,
|
checkout.stderr.toString().trim() || checkout.text().trim() || `Failed to checkout ${input.branch}`,
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +267,10 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
|
|||||||
|
|
||||||
const reset = yield* services.git.run(["reset", "--hard", target], { cwd: localPath })
|
const reset = yield* services.git.run(["reset", "--hard", target], { cwd: localPath })
|
||||||
if (reset.exitCode !== 0) {
|
if (reset.exitCode !== 0) {
|
||||||
throw new Error(reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`)
|
return yield* new ResetFailedError({
|
||||||
|
repository,
|
||||||
|
message: reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,4 +292,23 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service> = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const git = yield* Git.Service
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
ensure: Effect.fn("RepositoryCache.ensure")(function* (input) {
|
||||||
|
return yield* ensureWithServices(input, { fs, git })
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||||
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
Layer.provide(Git.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
export * as RepositoryCache from "./repository-cache"
|
export * as RepositoryCache from "./repository-cache"
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ function eventData(data: unknown): Sse.Event {
|
|||||||
|
|
||||||
function eventResponse(bus: Bus.Interface) {
|
function eventResponse(bus: Bus.Interface) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const context = yield* Effect.context()
|
// Subscribe eagerly: the bus subscription is acquired in the request scope
|
||||||
|
// at this yield, so any publish from now on is queued for the body-pump
|
||||||
const events = bus.subscribeAll().pipe(
|
// fiber to drain — closing the race where Stream.concat(server.connected,
|
||||||
Stream.provideContext(context),
|
// lazy-subscribe) used to drop publishes in the prefix-consume window.
|
||||||
|
const events = (yield* bus.subscribeAll()).pipe(
|
||||||
Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type),
|
Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type),
|
||||||
)
|
)
|
||||||
const heartbeat = Stream.tick("10 seconds").pipe(
|
const heartbeat = Stream.tick("10 seconds").pipe(
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { Provider } from "@/provider/provider"
|
|||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { Context, Effect, Layer, Record } from "effect"
|
import { Context, Effect, Layer, Record } from "effect"
|
||||||
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 as aiTool, jsonSchema } from "ai"
|
||||||
|
import type { LLMEvent } from "@opencode-ai/llm"
|
||||||
|
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
||||||
|
import type { LLMClientService } from "@opencode-ai/llm/route"
|
||||||
import { mergeDeep } from "remeda"
|
import { mergeDeep } from "remeda"
|
||||||
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
|
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
|
||||||
import { ProviderTransform } from "@/provider/transform"
|
import { ProviderTransform } from "@/provider/transform"
|
||||||
@@ -23,10 +26,11 @@ import { EffectBridge } from "@/effect/bridge"
|
|||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import * as Option from "effect/Option"
|
import * as Option from "effect/Option"
|
||||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||||
|
import { LLMAISDK } from "./llm/ai-sdk"
|
||||||
|
import { LLMNativeRuntime } from "./llm/native-runtime"
|
||||||
|
|
||||||
const log = Log.create({ service: "llm" })
|
const log = Log.create({ service: "llm" })
|
||||||
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
|
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
|
||||||
type Result = Awaited<ReturnType<typeof streamText>>
|
|
||||||
|
|
||||||
// Avoid re-instantiating remeda's deep merge types in this hot LLM path; the runtime behavior is still mergeDeep.
|
// Avoid re-instantiating remeda's deep merge types in this hot LLM path; the runtime behavior is still mergeDeep.
|
||||||
const mergeOptions = (target: Record<string, any>, source: Record<string, any> | undefined): Record<string, any> =>
|
const mergeOptions = (target: Record<string, any>, source: Record<string, any> | undefined): Record<string, any> =>
|
||||||
@@ -51,10 +55,8 @@ export type StreamRequest = StreamInput & {
|
|||||||
abort: AbortSignal
|
abort: AbortSignal
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Event = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
|
readonly stream: (input: StreamInput) => Stream.Stream<LLMEvent, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
|
||||||
@@ -62,7 +64,13 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/LL
|
|||||||
const live: Layer.Layer<
|
const live: Layer.Layer<
|
||||||
Service,
|
Service,
|
||||||
never,
|
never,
|
||||||
Auth.Service | Config.Service | Provider.Service | Plugin.Service | Permission.Service | RuntimeFlags.Service
|
| Auth.Service
|
||||||
|
| Config.Service
|
||||||
|
| Provider.Service
|
||||||
|
| Plugin.Service
|
||||||
|
| Permission.Service
|
||||||
|
| LLMClientService
|
||||||
|
| RuntimeFlags.Service
|
||||||
> = Layer.effect(
|
> = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -71,6 +79,7 @@ const live: Layer.Layer<
|
|||||||
const provider = yield* Provider.Service
|
const provider = yield* Provider.Service
|
||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
const perm = yield* Permission.Service
|
const perm = yield* Permission.Service
|
||||||
|
const llmClient = yield* LLMClient.Service
|
||||||
const flags = yield* RuntimeFlags.Service
|
const flags = yield* RuntimeFlags.Service
|
||||||
|
|
||||||
const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
|
const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
|
||||||
@@ -202,7 +211,7 @@ const live: Layer.Layer<
|
|||||||
Object.keys(tools).length === 0 &&
|
Object.keys(tools).length === 0 &&
|
||||||
hasToolCalls(input.messages)
|
hasToolCalls(input.messages)
|
||||||
) {
|
) {
|
||||||
tools["_noop"] = tool({
|
tools["_noop"] = aiTool({
|
||||||
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
|
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
|
||||||
inputSchema: jsonSchema({
|
inputSchema: jsonSchema({
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -322,7 +331,77 @@ const live: Layer.Layer<
|
|||||||
? (yield* InstanceState.context).project.id
|
? (yield* InstanceState.context).project.id
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
return streamText({
|
const requestHeaders = {
|
||||||
|
...(input.model.providerID.startsWith("opencode")
|
||||||
|
? {
|
||||||
|
...(opencodeProjectID ? { "x-opencode-project": opencodeProjectID } : {}),
|
||||||
|
"x-opencode-session": input.sessionID,
|
||||||
|
"x-opencode-request": input.user.id,
|
||||||
|
"x-opencode-client": flags.client,
|
||||||
|
"User-Agent": `opencode/${InstallationVersion}`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
"x-session-affinity": input.sessionID,
|
||||||
|
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
|
||||||
|
"User-Agent": `opencode/${InstallationVersion}`,
|
||||||
|
}),
|
||||||
|
...input.model.headers,
|
||||||
|
...headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flags.experimentalNativeLlm) {
|
||||||
|
const native = LLMNativeRuntime.stream({
|
||||||
|
model: input.model,
|
||||||
|
provider: item,
|
||||||
|
auth: info,
|
||||||
|
llmClient,
|
||||||
|
isOpenaiOauth,
|
||||||
|
system,
|
||||||
|
messages,
|
||||||
|
tools: sortedTools,
|
||||||
|
toolChoice: input.toolChoice,
|
||||||
|
temperature: params.temperature,
|
||||||
|
topP: params.topP,
|
||||||
|
topK: params.topK,
|
||||||
|
maxOutputTokens: params.maxOutputTokens,
|
||||||
|
providerOptions: params.options,
|
||||||
|
headers: requestHeaders,
|
||||||
|
abort: input.abort,
|
||||||
|
})
|
||||||
|
if (native.type === "supported") {
|
||||||
|
yield* Effect.logInfo("llm runtime selected").pipe(
|
||||||
|
Effect.annotateLogs({
|
||||||
|
"llm.runtime": "native",
|
||||||
|
"llm.provider": input.model.providerID,
|
||||||
|
"llm.model": input.model.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
type: "native" as const,
|
||||||
|
stream: native.stream,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield* Effect.logInfo("llm runtime selected").pipe(
|
||||||
|
Effect.annotateLogs({
|
||||||
|
"llm.runtime": "ai-sdk",
|
||||||
|
"llm.provider": input.model.providerID,
|
||||||
|
"llm.model": input.model.id,
|
||||||
|
"llm.native_unsupported_reason": native.reason,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
l.info("native runtime unavailable; falling back to ai-sdk", { reason: native.reason })
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* Effect.logInfo("llm runtime selected").pipe(
|
||||||
|
Effect.annotateLogs({
|
||||||
|
"llm.runtime": "ai-sdk",
|
||||||
|
"llm.provider": input.model.providerID,
|
||||||
|
"llm.model": input.model.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
type: "ai-sdk" as const,
|
||||||
|
result: streamText({
|
||||||
onError(error) {
|
onError(error) {
|
||||||
l.error("stream error", {
|
l.error("stream error", {
|
||||||
error,
|
error,
|
||||||
@@ -358,23 +437,7 @@ const live: Layer.Layer<
|
|||||||
toolChoice: input.toolChoice,
|
toolChoice: input.toolChoice,
|
||||||
maxOutputTokens: params.maxOutputTokens,
|
maxOutputTokens: params.maxOutputTokens,
|
||||||
abortSignal: input.abort,
|
abortSignal: input.abort,
|
||||||
headers: {
|
headers: requestHeaders,
|
||||||
...(input.model.providerID.startsWith("opencode")
|
|
||||||
? {
|
|
||||||
"x-opencode-project": opencodeProjectID,
|
|
||||||
"x-opencode-session": input.sessionID,
|
|
||||||
"x-opencode-request": input.user.id,
|
|
||||||
"x-opencode-client": flags.client,
|
|
||||||
"User-Agent": `opencode/${InstallationVersion}`,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
"x-session-affinity": input.sessionID,
|
|
||||||
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
|
|
||||||
"User-Agent": `opencode/${InstallationVersion}`,
|
|
||||||
}),
|
|
||||||
...input.model.headers,
|
|
||||||
...headers,
|
|
||||||
},
|
|
||||||
maxRetries: input.retries ?? 0,
|
maxRetries: input.retries ?? 0,
|
||||||
messages,
|
messages,
|
||||||
model: wrapLanguageModel({
|
model: wrapLanguageModel({
|
||||||
@@ -401,7 +464,8 @@ const live: Layer.Layer<
|
|||||||
sessionId: input.sessionID,
|
sessionId: input.sessionID,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const stream: Interface["stream"] = (input) =>
|
const stream: Interface["stream"] = (input) =>
|
||||||
@@ -415,7 +479,15 @@ const live: Layer.Layer<
|
|||||||
|
|
||||||
const result = yield* run({ ...input, abort: ctrl.signal })
|
const result = yield* run({ ...input, abort: ctrl.signal })
|
||||||
|
|
||||||
return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e))))
|
if (result.type === "native") return result.stream
|
||||||
|
|
||||||
|
const state = LLMAISDK.adapterState()
|
||||||
|
return Stream.fromAsyncIterable(result.result.fullStream, (e) =>
|
||||||
|
e instanceof Error ? e : new Error(String(e)),
|
||||||
|
).pipe(
|
||||||
|
Stream.mapEffect((event) => LLMAISDK.toLLMEvents(state, event)),
|
||||||
|
Stream.flatMap((events) => Stream.fromIterable(events)),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -432,6 +504,7 @@ export const defaultLayer = Layer.suspend(() =>
|
|||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Provider.defaultLayer),
|
Layer.provide(Provider.defaultLayer),
|
||||||
Layer.provide(Plugin.defaultLayer),
|
Layer.provide(Plugin.defaultLayer),
|
||||||
|
Layer.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer))),
|
||||||
Layer.provide(RuntimeFlags.defaultLayer),
|
Layer.provide(RuntimeFlags.defaultLayer),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Session LLM Runtime Boundaries
|
||||||
|
|
||||||
|
`../llm.ts` is the opencode session LLM service. It owns opencode concerns: auth, config, model/provider resolution, plugins, permissions, telemetry headers, and runtime selection.
|
||||||
|
|
||||||
|
This folder contains adapters behind that service boundary:
|
||||||
|
|
||||||
|
- `ai-sdk.ts` converts AI SDK `fullStream` parts into `@opencode-ai/llm` `LLMEvent`s. This is the default runtime path.
|
||||||
|
- `native-request.ts` converts opencode's normalized session input into a native `@opencode-ai/llm` `LLMRequest`. It does not execute requests.
|
||||||
|
- `native-runtime.ts` is the opt-in native runtime adapter. It decides whether a selected model is supported, builds the native request, bridges opencode tools into native executable tools, and delegates transport to `LLMClient` / `RequestExecutor`.
|
||||||
|
|
||||||
|
## Runtime selection
|
||||||
|
|
||||||
|
Both runtimes converge on the same `LLMEvent` stream consumed by the session processor. The gate is per-request: a single session can route some calls through native and fall back for others.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
╭───────────────────╮
|
||||||
|
╭───────────────────────────▶│ session processor │
|
||||||
|
│ ╰─────────┬─────────╯
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ ╭─────────────────────────╮
|
||||||
|
│ │ LLM.Service (../llm.ts) │
|
||||||
|
│ ╰────────────┬────────────╯
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ ╭───────────╮
|
||||||
|
│ ╭─╯ ╰─╮
|
||||||
|
│ │ native gate │
|
||||||
|
│ ╰─╮ ╭─╯
|
||||||
|
│ ╰─────┬─────╯
|
||||||
|
│ │
|
||||||
|
│ ╭────── no ──────┴─────── yes ────────╮
|
||||||
|
│ │ │
|
||||||
|
│ ▼ ▼
|
||||||
|
│ ╭───────────────────────────╮ ╭───────────────────╮
|
||||||
|
│ │ AI SDK │ │ native-runtime.ts │
|
||||||
|
│ │ streamText / generateText │ ╰────────┬──────────╯
|
||||||
|
│ ╰─────────────┬─────────────╯ │
|
||||||
|
│ │ │
|
||||||
|
│ ╭───╯ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ ▼
|
||||||
|
│ ╭───────────────────────╮ ╭────────────────────────────╮
|
||||||
|
│ │ ai-sdk.ts │ │ native-request.ts │
|
||||||
|
│ │ fullStream → LLMEvent │ │ session input → LLMRequest │
|
||||||
|
│ ╰──────────┬────────────╯ ╰──────────────┬─────────────╯
|
||||||
|
│ │ │
|
||||||
|
│ │ ╭───╯
|
||||||
|
│ │ │
|
||||||
|
│ ▼ ▼
|
||||||
|
│ ╭─────────────────╮ ╭─────────────────────────────╮
|
||||||
|
╰───────┤ LLMEvent stream │◀────────────┤ LLMClient · RequestExecutor │
|
||||||
|
╰─────────────────╯ ╰─────────────────────────────╯
|
||||||
|
```
|
||||||
|
|
||||||
|
`native-runtime.ts` evaluates the gate and either bridges into `@opencode-ai/llm` or returns control so `llm.ts` can take the AI SDK path. Tool execution stays opencode-owned in both branches; only request lowering and transport differ.
|
||||||
|
|
||||||
|
Safety boundary:
|
||||||
|
|
||||||
|
- AI SDK remains the default.
|
||||||
|
- `OPENCODE_EXPERIMENTAL_NATIVE_LLM=true` or the umbrella `OPENCODE_EXPERIMENTAL=true` opts in. Native is not a global replacement.
|
||||||
|
- Native execution currently runs only for OpenAI-compatible Responses models exposed through `@ai-sdk/openai`: direct `openai` API-key auth and console-managed `opencode`/Zen API-key config.
|
||||||
|
- Unsupported providers, OpenAI OAuth, and missing API-key cases fall back to AI SDK.
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { FinishReason, LLMEvent, ProviderMetadata, ToolResultValue } from "@opencode-ai/llm"
|
||||||
|
import { Effect, Schema } from "effect"
|
||||||
|
import { type streamText } from "ai"
|
||||||
|
import { errorMessage } from "@/util/error"
|
||||||
|
|
||||||
|
type Result = Awaited<ReturnType<typeof streamText>>
|
||||||
|
type AISDKEvent = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
|
||||||
|
|
||||||
|
export function adapterState() {
|
||||||
|
return {
|
||||||
|
step: 0,
|
||||||
|
text: 0,
|
||||||
|
reasoning: 0,
|
||||||
|
currentTextID: undefined as string | undefined,
|
||||||
|
currentReasoningID: undefined as string | undefined,
|
||||||
|
toolNames: {} as Record<string, string>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishReason(value: string | undefined): FinishReason {
|
||||||
|
return Schema.is(FinishReason)(value) ? value : "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerMetadata(value: unknown): ProviderMetadata | undefined {
|
||||||
|
if (value == null) return undefined
|
||||||
|
return Schema.is(ProviderMetadata)(value) ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function usage(value: unknown) {
|
||||||
|
if (!value || typeof value !== "object") return undefined
|
||||||
|
const item = value as {
|
||||||
|
inputTokens?: number
|
||||||
|
outputTokens?: number
|
||||||
|
totalTokens?: number
|
||||||
|
reasoningTokens?: number
|
||||||
|
cachedInputTokens?: number
|
||||||
|
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
|
||||||
|
outputTokenDetails?: { reasoningTokens?: number }
|
||||||
|
}
|
||||||
|
const entries = Object.entries({
|
||||||
|
inputTokens: item.inputTokens,
|
||||||
|
outputTokens: item.outputTokens,
|
||||||
|
totalTokens: item.totalTokens,
|
||||||
|
reasoningTokens: item.outputTokenDetails?.reasoningTokens ?? item.reasoningTokens,
|
||||||
|
cacheReadInputTokens: item.inputTokenDetails?.cacheReadTokens ?? item.cachedInputTokens,
|
||||||
|
cacheWriteInputTokens: item.inputTokenDetails?.cacheWriteTokens,
|
||||||
|
}).filter((entry) => entry[1] !== undefined)
|
||||||
|
return entries.length === 0 ? undefined : Object.fromEntries(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentTextID(state: ReturnType<typeof adapterState>, id: string | undefined) {
|
||||||
|
state.currentTextID = id ?? state.currentTextID ?? `text-${state.text++}`
|
||||||
|
return state.currentTextID
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentReasoningID(state: ReturnType<typeof adapterState>, id: string | undefined) {
|
||||||
|
state.currentReasoningID = id ?? state.currentReasoningID ?? `reasoning-${state.reasoning++}`
|
||||||
|
return state.currentReasoningID
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toLLMEvents(
|
||||||
|
state: ReturnType<typeof adapterState>,
|
||||||
|
event: AISDKEvent,
|
||||||
|
): Effect.Effect<ReadonlyArray<LLMEvent>, unknown> {
|
||||||
|
switch (event.type) {
|
||||||
|
case "start":
|
||||||
|
return Effect.succeed([])
|
||||||
|
|
||||||
|
case "start-step":
|
||||||
|
return Effect.succeed([LLMEvent.stepStart({ index: state.step })])
|
||||||
|
|
||||||
|
case "finish-step":
|
||||||
|
return Effect.sync(() => [
|
||||||
|
LLMEvent.stepFinish({
|
||||||
|
index: state.step++,
|
||||||
|
reason: finishReason(event.finishReason),
|
||||||
|
usage: usage(event.usage),
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
case "finish":
|
||||||
|
return Effect.sync(() => {
|
||||||
|
const events = [
|
||||||
|
LLMEvent.finish({
|
||||||
|
reason: finishReason(event.finishReason),
|
||||||
|
usage: usage(event.totalUsage),
|
||||||
|
providerMetadata: "providerMetadata" in event ? providerMetadata(event.providerMetadata) : undefined,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
// Reset so the adapter can be reused for a follow-up stream without leaking
|
||||||
|
// counters or block IDs. adapterState() is the single source of truth for shape.
|
||||||
|
Object.assign(state, adapterState())
|
||||||
|
return events
|
||||||
|
})
|
||||||
|
|
||||||
|
case "text-start":
|
||||||
|
return Effect.sync(() => {
|
||||||
|
state.currentTextID = currentTextID(state, event.id)
|
||||||
|
return [
|
||||||
|
LLMEvent.textStart({
|
||||||
|
id: state.currentTextID,
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
case "text-delta":
|
||||||
|
return Effect.succeed([
|
||||||
|
LLMEvent.textDelta({
|
||||||
|
id: currentTextID(state, event.id),
|
||||||
|
text: event.text,
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
case "text-end":
|
||||||
|
return Effect.sync(() => {
|
||||||
|
const id = currentTextID(state, event.id)
|
||||||
|
state.currentTextID = undefined
|
||||||
|
return [
|
||||||
|
LLMEvent.textEnd({
|
||||||
|
id,
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
case "reasoning-start":
|
||||||
|
return Effect.sync(() => {
|
||||||
|
state.currentReasoningID = currentReasoningID(state, event.id)
|
||||||
|
return [
|
||||||
|
LLMEvent.reasoningStart({
|
||||||
|
id: state.currentReasoningID,
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
case "reasoning-delta":
|
||||||
|
return Effect.succeed([
|
||||||
|
LLMEvent.reasoningDelta({
|
||||||
|
id: currentReasoningID(state, event.id),
|
||||||
|
text: event.text,
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
case "reasoning-end":
|
||||||
|
return Effect.sync(() => {
|
||||||
|
const id = currentReasoningID(state, event.id)
|
||||||
|
state.currentReasoningID = undefined
|
||||||
|
return [
|
||||||
|
LLMEvent.reasoningEnd({
|
||||||
|
id,
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
case "tool-input-start":
|
||||||
|
return Effect.sync(() => {
|
||||||
|
state.toolNames[event.id] = event.toolName
|
||||||
|
return [
|
||||||
|
LLMEvent.toolInputStart({
|
||||||
|
id: event.id,
|
||||||
|
name: event.toolName,
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
case "tool-input-delta":
|
||||||
|
return Effect.succeed([
|
||||||
|
LLMEvent.toolInputDelta({
|
||||||
|
id: event.id,
|
||||||
|
name: state.toolNames[event.id] ?? "unknown",
|
||||||
|
text: event.delta ?? "",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
case "tool-input-end":
|
||||||
|
return Effect.succeed([
|
||||||
|
LLMEvent.toolInputEnd({
|
||||||
|
id: event.id,
|
||||||
|
name: state.toolNames[event.id] ?? "unknown",
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
case "tool-call":
|
||||||
|
return Effect.sync(() => {
|
||||||
|
state.toolNames[event.toolCallId] = event.toolName
|
||||||
|
return [
|
||||||
|
LLMEvent.toolCall({
|
||||||
|
id: event.toolCallId,
|
||||||
|
name: event.toolName,
|
||||||
|
input: event.input,
|
||||||
|
providerExecuted: "providerExecuted" in event ? event.providerExecuted : undefined,
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
case "tool-result":
|
||||||
|
return Effect.sync(() => {
|
||||||
|
const name = state.toolNames[event.toolCallId] ?? "unknown"
|
||||||
|
delete state.toolNames[event.toolCallId]
|
||||||
|
return [
|
||||||
|
LLMEvent.toolResult({
|
||||||
|
id: event.toolCallId,
|
||||||
|
name,
|
||||||
|
result: ToolResultValue.make(event.output),
|
||||||
|
providerExecuted: "providerExecuted" in event ? event.providerExecuted : undefined,
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
case "tool-error":
|
||||||
|
return Effect.sync(() => {
|
||||||
|
const name = state.toolNames[event.toolCallId] ?? ("toolName" in event ? event.toolName : "unknown")
|
||||||
|
delete state.toolNames[event.toolCallId]
|
||||||
|
return [
|
||||||
|
LLMEvent.toolError({
|
||||||
|
id: event.toolCallId,
|
||||||
|
name,
|
||||||
|
message: errorMessage(event.error),
|
||||||
|
error: event.error,
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
case "error":
|
||||||
|
return Effect.fail(event.error)
|
||||||
|
|
||||||
|
case "abort":
|
||||||
|
case "source":
|
||||||
|
case "file":
|
||||||
|
case "raw":
|
||||||
|
case "tool-output-denied":
|
||||||
|
case "tool-approval-request":
|
||||||
|
return Effect.succeed([])
|
||||||
|
|
||||||
|
default: {
|
||||||
|
const _exhaustive: never = event
|
||||||
|
void _exhaustive
|
||||||
|
return Effect.succeed([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as LLMAISDK from "./ai-sdk"
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import type { JsonSchema, LLMRequest, ProviderMetadata } from "@opencode-ai/llm"
|
||||||
|
import { LLM, Message, SystemPart, ToolCallPart, ToolDefinition, ToolResultPart } from "@opencode-ai/llm"
|
||||||
|
import "@opencode-ai/llm/providers"
|
||||||
|
import type { ModelMessage } from "ai"
|
||||||
|
import type { Provider } from "@/provider/provider"
|
||||||
|
import { isRecord } from "@/util/record"
|
||||||
|
|
||||||
|
type ToolInput = {
|
||||||
|
readonly description?: string
|
||||||
|
readonly inputSchema?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestInput = {
|
||||||
|
readonly model: Provider.Model
|
||||||
|
readonly apiKey?: string
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly system?: readonly string[]
|
||||||
|
readonly messages: readonly ModelMessage[]
|
||||||
|
readonly tools?: Record<string, ToolInput>
|
||||||
|
readonly toolChoice?: "auto" | "required" | "none"
|
||||||
|
readonly temperature?: number
|
||||||
|
readonly topP?: number
|
||||||
|
readonly topK?: number
|
||||||
|
readonly maxOutputTokens?: number
|
||||||
|
readonly providerOptions?: LLMRequest["providerOptions"]
|
||||||
|
readonly headers?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL: Record<string, string> = {
|
||||||
|
"@ai-sdk/openai": "https://api.openai.com/v1",
|
||||||
|
"@ai-sdk/anthropic": "https://api.anthropic.com/v1",
|
||||||
|
"@ai-sdk/google": "https://generativelanguage.googleapis.com/v1beta",
|
||||||
|
"@ai-sdk/amazon-bedrock": "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||||
|
"@openrouter/ai-sdk-provider": "https://openrouter.ai/api/v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROUTE: Record<string, string> = {
|
||||||
|
"@ai-sdk/openai": "openai-responses",
|
||||||
|
"@ai-sdk/azure": "azure-openai-responses",
|
||||||
|
"@ai-sdk/anthropic": "anthropic-messages",
|
||||||
|
"@ai-sdk/google": "gemini",
|
||||||
|
"@ai-sdk/amazon-bedrock": "bedrock-converse",
|
||||||
|
"@ai-sdk/openai-compatible": "openai-compatible-chat",
|
||||||
|
"@openrouter/ai-sdk-provider": "openrouter",
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerMetadata = (value: unknown): ProviderMetadata | undefined => {
|
||||||
|
if (!isRecord(value)) return undefined
|
||||||
|
const result = Object.fromEntries(
|
||||||
|
Object.entries(value).filter((entry): entry is [string, Record<string, unknown>] => isRecord(entry[1])),
|
||||||
|
)
|
||||||
|
return Object.keys(result).length === 0 ? undefined : result
|
||||||
|
}
|
||||||
|
|
||||||
|
const textPart = (part: Record<string, unknown>) => ({
|
||||||
|
type: "text" as const,
|
||||||
|
text: typeof part.text === "string" ? part.text : "",
|
||||||
|
providerMetadata: providerMetadata(part.providerOptions),
|
||||||
|
})
|
||||||
|
|
||||||
|
const mediaPart = (part: Record<string, unknown>) => {
|
||||||
|
if (typeof part.data !== "string" && !(part.data instanceof Uint8Array))
|
||||||
|
throw new Error("Native LLM request adapter only supports file parts with string or Uint8Array data")
|
||||||
|
return {
|
||||||
|
type: "media" as const,
|
||||||
|
mediaType: typeof part.mediaType === "string" ? part.mediaType : "application/octet-stream",
|
||||||
|
data: part.data,
|
||||||
|
filename: typeof part.filename === "string" ? part.filename : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolResult = (part: Record<string, unknown>) => {
|
||||||
|
const output = isRecord(part.output) ? part.output : { type: "json", value: part.output }
|
||||||
|
const type = output.type === "text" ? "text" : output.type === "error-text" ? "error" : "json"
|
||||||
|
return ToolResultPart.make({
|
||||||
|
id: typeof part.toolCallId === "string" ? part.toolCallId : "",
|
||||||
|
name: typeof part.toolName === "string" ? part.toolName : "",
|
||||||
|
result: "value" in output ? output.value : output,
|
||||||
|
resultType: type,
|
||||||
|
providerExecuted: typeof part.providerExecuted === "boolean" ? part.providerExecuted : undefined,
|
||||||
|
providerMetadata: providerMetadata(part.providerOptions),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentPart = (part: unknown) => {
|
||||||
|
if (!isRecord(part)) throw new Error("Native LLM request adapter only supports object content parts")
|
||||||
|
if (part.type === "text") return textPart(part)
|
||||||
|
if (part.type === "file") return mediaPart(part)
|
||||||
|
if (part.type === "reasoning")
|
||||||
|
return {
|
||||||
|
type: "reasoning" as const,
|
||||||
|
text: typeof part.text === "string" ? part.text : "",
|
||||||
|
providerMetadata: providerMetadata(part.providerOptions),
|
||||||
|
}
|
||||||
|
if (part.type === "tool-call")
|
||||||
|
return ToolCallPart.make({
|
||||||
|
id: typeof part.toolCallId === "string" ? part.toolCallId : "",
|
||||||
|
name: typeof part.toolName === "string" ? part.toolName : "",
|
||||||
|
input: part.input,
|
||||||
|
providerExecuted: typeof part.providerExecuted === "boolean" ? part.providerExecuted : undefined,
|
||||||
|
providerMetadata: providerMetadata(part.providerOptions),
|
||||||
|
})
|
||||||
|
if (part.type === "tool-result") return toolResult(part)
|
||||||
|
throw new Error(`Native LLM request adapter does not support ${String(part.type)} content parts`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (value: ModelMessage["content"]) =>
|
||||||
|
typeof value === "string" ? [{ type: "text" as const, text: value }] : value.map(contentPart)
|
||||||
|
|
||||||
|
const messages = (input: readonly ModelMessage[]) => {
|
||||||
|
const system = input.flatMap((message) => (message.role === "system" ? [SystemPart.make(message.content)] : []))
|
||||||
|
const messages = input.flatMap((message) => {
|
||||||
|
if (message.role === "system") return []
|
||||||
|
return [
|
||||||
|
Message.make({
|
||||||
|
role: message.role,
|
||||||
|
content: content(message.content),
|
||||||
|
native: isRecord(message.providerOptions) ? { providerOptions: message.providerOptions } : undefined,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
return { system, messages }
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = (value: unknown): JsonSchema => {
|
||||||
|
if (!isRecord(value)) return { type: "object", properties: {} }
|
||||||
|
if (isRecord(value.jsonSchema)) return value.jsonSchema
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const tools = (input: Record<string, ToolInput> | undefined): ToolDefinition[] =>
|
||||||
|
Object.entries(input ?? {}).map(([name, item]) =>
|
||||||
|
ToolDefinition.make({
|
||||||
|
name,
|
||||||
|
description: item.description ?? "",
|
||||||
|
inputSchema: schema(item.inputSchema),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const generation = (input: RequestInput) => {
|
||||||
|
const result = {
|
||||||
|
temperature: input.temperature,
|
||||||
|
topP: input.topP,
|
||||||
|
topK: input.topK,
|
||||||
|
maxTokens: input.maxOutputTokens,
|
||||||
|
}
|
||||||
|
return Object.values(result).some((value) => value !== undefined) ? result : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseURL = (model: Provider.Model) => {
|
||||||
|
if (model.api.url) return model.api.url
|
||||||
|
const fallback = DEFAULT_BASE_URL[model.api.npm]
|
||||||
|
if (fallback) return fallback
|
||||||
|
throw new Error(`Native LLM request adapter requires a base URL for ${model.providerID}/${model.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const model = (input: Provider.Model | RequestInput, headers?: Record<string, string>) => {
|
||||||
|
const model = "model" in input ? input.model : input
|
||||||
|
const route = ROUTE[model.api.npm]
|
||||||
|
if (!route) throw new Error(`Native LLM request adapter does not support provider package ${model.api.npm}`)
|
||||||
|
return LLM.model({
|
||||||
|
id: model.api.id,
|
||||||
|
provider: model.providerID,
|
||||||
|
route,
|
||||||
|
baseURL: "model" in input && input.baseURL ? input.baseURL : baseURL(model),
|
||||||
|
apiKey: "model" in input ? input.apiKey : undefined,
|
||||||
|
headers: Object.keys({ ...model.headers, ...headers }).length === 0 ? undefined : { ...model.headers, ...headers },
|
||||||
|
limits: {
|
||||||
|
context: model.limit.context,
|
||||||
|
output: model.limit.output,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const request = (input: RequestInput) => {
|
||||||
|
const converted = messages(input.messages)
|
||||||
|
return LLM.request({
|
||||||
|
model: model(input, input.headers),
|
||||||
|
system: [...(input.system ?? []).map(SystemPart.make), ...converted.system],
|
||||||
|
messages: converted.messages,
|
||||||
|
tools: tools(input.tools),
|
||||||
|
toolChoice: input.toolChoice,
|
||||||
|
generation: generation(input),
|
||||||
|
providerOptions: input.providerOptions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as LLMNative from "./native-request"
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { Auth } from "@/auth"
|
||||||
|
import type { Provider } from "@/provider/provider"
|
||||||
|
import { ProviderTransform } from "@/provider/transform"
|
||||||
|
import { errorMessage } from "@/util/error"
|
||||||
|
import { isRecord } from "@/util/record"
|
||||||
|
import { asSchema, type ModelMessage, type Tool } from "ai"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import * as Stream from "effect/Stream"
|
||||||
|
import { tool as nativeTool, ToolFailure, type JsonSchema, type LLMEvent } from "@opencode-ai/llm"
|
||||||
|
import type { LLMClientShape } from "@opencode-ai/llm/route"
|
||||||
|
import { LLMNative } from "./native-request"
|
||||||
|
|
||||||
|
export type RuntimeStatus =
|
||||||
|
| { readonly type: "supported"; readonly apiKey: string; readonly baseURL?: string }
|
||||||
|
| { readonly type: "unsupported"; readonly reason: string }
|
||||||
|
export type StreamResult =
|
||||||
|
| { readonly type: "supported"; readonly stream: Stream.Stream<LLMEvent, unknown> }
|
||||||
|
| { readonly type: "unsupported"; readonly reason: string }
|
||||||
|
|
||||||
|
type StreamInput = {
|
||||||
|
readonly model: Provider.Model
|
||||||
|
readonly provider: Provider.Info
|
||||||
|
readonly auth: Auth.Info | undefined
|
||||||
|
readonly llmClient: LLMClientShape
|
||||||
|
readonly isOpenaiOauth: boolean
|
||||||
|
readonly system: string[]
|
||||||
|
readonly messages: ModelMessage[]
|
||||||
|
readonly tools: Record<string, Tool>
|
||||||
|
readonly toolChoice?: "auto" | "required" | "none"
|
||||||
|
readonly temperature?: number
|
||||||
|
readonly topP?: number
|
||||||
|
readonly topK?: number
|
||||||
|
readonly maxOutputTokens?: number
|
||||||
|
readonly providerOptions?: Record<string, any>
|
||||||
|
readonly headers: Record<string, string>
|
||||||
|
readonly abort: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
export function status(input: Pick<StreamInput, "model" | "provider" | "auth">): RuntimeStatus {
|
||||||
|
if (input.model.providerID !== "openai" && !input.model.providerID.startsWith("opencode"))
|
||||||
|
return { type: "unsupported", reason: "provider is not openai or opencode" }
|
||||||
|
if (input.model.api.npm !== "@ai-sdk/openai") return { type: "unsupported", reason: "provider package is not OpenAI" }
|
||||||
|
if (input.auth?.type === "oauth") return { type: "unsupported", reason: "OAuth auth is not supported" }
|
||||||
|
|
||||||
|
const apiKey = typeof input.provider.options.apiKey === "string" ? input.provider.options.apiKey : input.provider.key
|
||||||
|
if (!apiKey) return { type: "unsupported", reason: "OpenAI API key is not configured" }
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "supported",
|
||||||
|
apiKey,
|
||||||
|
baseURL: typeof input.provider.options.baseURL === "string" ? input.provider.options.baseURL : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stream(input: StreamInput): StreamResult {
|
||||||
|
const current = status(input)
|
||||||
|
if (current.type === "unsupported") return current
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
stream: input.llmClient.stream({
|
||||||
|
request: LLMNative.request({
|
||||||
|
model: input.model,
|
||||||
|
apiKey: current.apiKey,
|
||||||
|
baseURL: current.baseURL,
|
||||||
|
system: input.isOpenaiOauth ? input.system : [],
|
||||||
|
messages: ProviderTransform.message(input.messages, input.model, input.providerOptions ?? {}),
|
||||||
|
toolChoice: input.toolChoice,
|
||||||
|
temperature: input.temperature,
|
||||||
|
topP: input.topP,
|
||||||
|
topK: input.topK,
|
||||||
|
maxOutputTokens: input.maxOutputTokens,
|
||||||
|
providerOptions: ProviderTransform.providerOptions(input.model, input.providerOptions ?? {}),
|
||||||
|
headers: { ...providerHeaders(input.provider.options.headers), ...input.headers },
|
||||||
|
}),
|
||||||
|
tools: nativeTools(input.tools, input),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerHeaders(value: unknown): Record<string, string> | undefined {
|
||||||
|
if (!isRecord(value)) return undefined
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeSchema(value: unknown): JsonSchema {
|
||||||
|
if (!value || typeof value !== "object") return { type: "object", properties: {} }
|
||||||
|
if ("jsonSchema" in value && value.jsonSchema && typeof value.jsonSchema === "object")
|
||||||
|
return value.jsonSchema as JsonSchema
|
||||||
|
return asSchema(value as Parameters<typeof asSchema>[0]).jsonSchema as JsonSchema
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nativeTools(tools: Record<string, Tool>, input: Pick<StreamInput, "messages" | "abort">) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(tools).map(([name, item]) => [
|
||||||
|
name,
|
||||||
|
nativeTool({
|
||||||
|
description: item.description ?? "",
|
||||||
|
jsonSchema: nativeSchema(item.inputSchema),
|
||||||
|
execute: (args: unknown, ctx) =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: () => {
|
||||||
|
if (!item.execute) throw new Error(`Tool has no execute handler: ${name}`)
|
||||||
|
return item.execute(args, {
|
||||||
|
toolCallId: ctx?.id ?? name,
|
||||||
|
messages: input.messages,
|
||||||
|
abortSignal: input.abort,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
catch: (error) => new ToolFailure({ message: errorMessage(error), error }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as LLMNativeRuntime from "./native-runtime"
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Cause, Deferred, Effect, Exit, Layer, Context, Scope } from "effect"
|
import { Image } from "@/image/image"
|
||||||
|
import { Cause, Deferred, Effect, Exit, Layer, Context, Scope, Schema } from "effect"
|
||||||
import * as Stream from "effect/Stream"
|
import * as Stream from "effect/Stream"
|
||||||
import { Agent } from "@/agent/agent"
|
import { Agent } from "@/agent/agent"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
@@ -9,7 +10,6 @@ import { Snapshot } from "@/snapshot"
|
|||||||
import * as Session from "./session"
|
import * as Session from "./session"
|
||||||
import { LLM } from "./llm"
|
import { LLM } from "./llm"
|
||||||
import { MessageV2 } from "./message-v2"
|
import { MessageV2 } from "./message-v2"
|
||||||
import { Image } from "@/image/image"
|
|
||||||
import { isOverflow } from "./overflow"
|
import { isOverflow } from "./overflow"
|
||||||
import { PartID } from "./schema"
|
import { PartID } from "./schema"
|
||||||
import type { SessionID } from "./schema"
|
import type { SessionID } from "./schema"
|
||||||
@@ -28,14 +28,13 @@ import { ModelV2 } from "@opencode-ai/core/model"
|
|||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import * as DateTime from "effect/DateTime"
|
import * as DateTime from "effect/DateTime"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
|
import { Usage, type LLMEvent } from "@opencode-ai/llm"
|
||||||
|
|
||||||
const DOOM_LOOP_THRESHOLD = 3
|
const DOOM_LOOP_THRESHOLD = 3
|
||||||
const log = Log.create({ service: "session.processor" })
|
const log = Log.create({ service: "session.processor" })
|
||||||
|
|
||||||
export type Result = "compact" | "stop" | "continue"
|
export type Result = "compact" | "stop" | "continue"
|
||||||
|
|
||||||
export type Event = LLM.Event
|
|
||||||
|
|
||||||
export interface Handle {
|
export interface Handle {
|
||||||
readonly message: MessageV2.Assistant
|
readonly message: MessageV2.Assistant
|
||||||
readonly updateToolCall: (
|
readonly updateToolCall: (
|
||||||
@@ -69,6 +68,7 @@ type ToolCall = {
|
|||||||
messageID: MessageV2.ToolPart["messageID"]
|
messageID: MessageV2.ToolPart["messageID"]
|
||||||
sessionID: MessageV2.ToolPart["sessionID"]
|
sessionID: MessageV2.ToolPart["sessionID"]
|
||||||
done: Deferred.Deferred<void>
|
done: Deferred.Deferred<void>
|
||||||
|
inputEnded: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProcessorContext extends Input {
|
interface ProcessorContext extends Input {
|
||||||
@@ -81,7 +81,7 @@ interface ProcessorContext extends Input {
|
|||||||
reasoningMap: Record<string, MessageV2.ReasoningPart>
|
reasoningMap: Record<string, MessageV2.ReasoningPart>
|
||||||
}
|
}
|
||||||
|
|
||||||
type StreamEvent = Event
|
type StreamEvent = LLMEvent
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionProcessor") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionProcessor") {}
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) {
|
const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) {
|
||||||
const call = ctx.toolcalls[toolCallID]
|
const call = ctx.toolcalls[toolCallID]
|
||||||
if (!call) return
|
if (!call) return undefined
|
||||||
const part = yield* session.getPart({
|
const part = yield* session.getPart({
|
||||||
partID: call.partID,
|
partID: call.partID,
|
||||||
messageID: call.messageID,
|
messageID: call.messageID,
|
||||||
@@ -145,7 +145,7 @@ export const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
if (!part || part.type !== "tool") {
|
if (!part || part.type !== "tool") {
|
||||||
delete ctx.toolcalls[toolCallID]
|
delete ctx.toolcalls[toolCallID]
|
||||||
return
|
return undefined
|
||||||
}
|
}
|
||||||
return { call, part }
|
return { call, part }
|
||||||
})
|
})
|
||||||
@@ -155,7 +155,7 @@ export const layer = Layer.effect(
|
|||||||
update: (part: MessageV2.ToolPart) => MessageV2.ToolPart,
|
update: (part: MessageV2.ToolPart) => MessageV2.ToolPart,
|
||||||
) {
|
) {
|
||||||
const match = yield* readToolCall(toolCallID)
|
const match = yield* readToolCall(toolCallID)
|
||||||
if (!match) return
|
if (!match) return undefined
|
||||||
const part = yield* session.updatePart(update(match.part))
|
const part = yield* session.updatePart(update(match.part))
|
||||||
ctx.toolcalls[toolCallID] = {
|
ctx.toolcalls[toolCallID] = {
|
||||||
...match.call,
|
...match.call,
|
||||||
@@ -211,12 +211,98 @@ export const layer = Layer.effect(
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const finishReasoning = Effect.fn("SessionProcessor.finishReasoning")(function* (reasoningID: string) {
|
||||||
|
if (!(reasoningID in ctx.reasoningMap)) return
|
||||||
|
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||||
|
if (flags.experimentalEventSystem) {
|
||||||
|
yield* events.publish(SessionEvent.Reasoning.Ended, {
|
||||||
|
sessionID: ctx.sessionID,
|
||||||
|
reasoningID,
|
||||||
|
text: ctx.reasoningMap[reasoningID].text,
|
||||||
|
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// oxlint-disable-next-line no-self-assign -- reactivity trigger
|
||||||
|
ctx.reasoningMap[reasoningID].text = ctx.reasoningMap[reasoningID].text
|
||||||
|
ctx.reasoningMap[reasoningID].time = { ...ctx.reasoningMap[reasoningID].time, end: Date.now() }
|
||||||
|
yield* session.updatePart(ctx.reasoningMap[reasoningID])
|
||||||
|
delete ctx.reasoningMap[reasoningID]
|
||||||
|
})
|
||||||
|
|
||||||
|
const ensureToolCall = Effect.fn("SessionProcessor.ensureToolCall")(function* (input: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
providerExecuted?: boolean
|
||||||
|
}) {
|
||||||
|
const existing = yield* readToolCall(input.id)
|
||||||
|
if (existing) {
|
||||||
|
if (!input.providerExecuted || existing.part.metadata?.providerExecuted) return existing
|
||||||
|
const part = yield* session.updatePart({
|
||||||
|
...existing.part,
|
||||||
|
metadata: { ...existing.part.metadata, providerExecuted: true },
|
||||||
|
})
|
||||||
|
ctx.toolcalls[input.id] = {
|
||||||
|
...existing.call,
|
||||||
|
partID: part.id,
|
||||||
|
messageID: part.messageID,
|
||||||
|
sessionID: part.sessionID,
|
||||||
|
}
|
||||||
|
return { call: ctx.toolcalls[input.id], part }
|
||||||
|
}
|
||||||
|
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||||
|
if (flags.experimentalEventSystem) {
|
||||||
|
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||||
|
sessionID: ctx.sessionID,
|
||||||
|
callID: input.id,
|
||||||
|
name: input.name,
|
||||||
|
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const part = yield* session.updatePart({
|
||||||
|
id: PartID.ascending(),
|
||||||
|
messageID: ctx.assistantMessage.id,
|
||||||
|
sessionID: ctx.assistantMessage.sessionID,
|
||||||
|
type: "tool",
|
||||||
|
tool: input.name,
|
||||||
|
callID: input.id,
|
||||||
|
state: { status: "pending", input: {}, raw: "" },
|
||||||
|
metadata: input.providerExecuted ? { providerExecuted: true } : undefined,
|
||||||
|
} satisfies MessageV2.ToolPart)
|
||||||
|
ctx.toolcalls[input.id] = {
|
||||||
|
done: yield* Deferred.make<void>(),
|
||||||
|
partID: part.id,
|
||||||
|
messageID: part.messageID,
|
||||||
|
sessionID: part.sessionID,
|
||||||
|
inputEnded: false,
|
||||||
|
}
|
||||||
|
return { call: ctx.toolcalls[input.id], part }
|
||||||
|
})
|
||||||
|
|
||||||
|
const isFilePart = Schema.is(MessageV2.FilePart)
|
||||||
|
|
||||||
|
const toolResultOutput = (value: Extract<StreamEvent, { type: "tool-result" }>) => {
|
||||||
|
if (isRecord(value.result.value) && typeof value.result.value.output === "string") {
|
||||||
|
return {
|
||||||
|
title: typeof value.result.value.title === "string" ? value.result.value.title : value.name,
|
||||||
|
metadata: isRecord(value.result.value.metadata) ? value.result.value.metadata : {},
|
||||||
|
output: value.result.value.output,
|
||||||
|
attachments: Array.isArray(value.result.value.attachments)
|
||||||
|
? value.result.value.attachments.filter(isFilePart)
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: value.name,
|
||||||
|
metadata: value.result.type === "json" && isRecord(value.result.value) ? value.result.value : {},
|
||||||
|
output:
|
||||||
|
typeof value.result.value === "string" ? value.result.value : (JSON.stringify(value.result.value) ?? ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolInput = (value: unknown): Record<string, any> => (isRecord(value) ? value : { value })
|
||||||
|
|
||||||
const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) {
|
const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) {
|
||||||
switch (value.type) {
|
switch (value.type) {
|
||||||
case "start":
|
|
||||||
yield* status.set(ctx.sessionID, { type: "busy" })
|
|
||||||
return
|
|
||||||
|
|
||||||
case "reasoning-start":
|
case "reasoning-start":
|
||||||
if (value.id in ctx.reasoningMap) return
|
if (value.id in ctx.reasoningMap) return
|
||||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||||
@@ -240,6 +326,7 @@ export const layer = Layer.effect(
|
|||||||
return
|
return
|
||||||
|
|
||||||
case "reasoning-delta":
|
case "reasoning-delta":
|
||||||
|
// Match dev: silently drop orphan deltas (no preceding reasoning-start).
|
||||||
if (!(value.id in ctx.reasoningMap)) return
|
if (!(value.id in ctx.reasoningMap)) return
|
||||||
ctx.reasoningMap[value.id].text += value.text
|
ctx.reasoningMap[value.id].text += value.text
|
||||||
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
|
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
|
||||||
@@ -253,59 +340,26 @@ export const layer = Layer.effect(
|
|||||||
return
|
return
|
||||||
|
|
||||||
case "reasoning-end":
|
case "reasoning-end":
|
||||||
if (!(value.id in ctx.reasoningMap)) return
|
if (value.providerMetadata && value.id in ctx.reasoningMap) {
|
||||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
ctx.reasoningMap[value.id].metadata = value.providerMetadata
|
||||||
if (flags.experimentalEventSystem) {
|
|
||||||
yield* events.publish(SessionEvent.Reasoning.Ended, {
|
|
||||||
sessionID: ctx.sessionID,
|
|
||||||
reasoningID: value.id,
|
|
||||||
text: ctx.reasoningMap[value.id].text,
|
|
||||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
// oxlint-disable-next-line no-self-assign -- reactivity trigger
|
yield* finishReasoning(value.id)
|
||||||
ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text
|
|
||||||
ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() }
|
|
||||||
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
|
|
||||||
yield* session.updatePart(ctx.reasoningMap[value.id])
|
|
||||||
delete ctx.reasoningMap[value.id]
|
|
||||||
return
|
return
|
||||||
|
|
||||||
case "tool-input-start":
|
case "tool-input-start":
|
||||||
if (ctx.assistantMessage.summary) {
|
if (ctx.assistantMessage.summary) {
|
||||||
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
throw new Error(`Tool call not allowed while generating summary: ${value.name}`)
|
||||||
}
|
|
||||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
|
||||||
if (flags.experimentalEventSystem) {
|
|
||||||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
|
||||||
sessionID: ctx.sessionID,
|
|
||||||
callID: value.id,
|
|
||||||
name: value.toolName,
|
|
||||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const part = yield* session.updatePart({
|
|
||||||
id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(),
|
|
||||||
messageID: ctx.assistantMessage.id,
|
|
||||||
sessionID: ctx.assistantMessage.sessionID,
|
|
||||||
type: "tool",
|
|
||||||
tool: value.toolName,
|
|
||||||
callID: value.id,
|
|
||||||
state: { status: "pending", input: {}, raw: "" },
|
|
||||||
metadata: value.providerExecuted ? { providerExecuted: true } : undefined,
|
|
||||||
} satisfies MessageV2.ToolPart)
|
|
||||||
ctx.toolcalls[value.id] = {
|
|
||||||
done: yield* Deferred.make<void>(),
|
|
||||||
partID: part.id,
|
|
||||||
messageID: part.messageID,
|
|
||||||
sessionID: part.sessionID,
|
|
||||||
}
|
}
|
||||||
|
yield* ensureToolCall(value)
|
||||||
return
|
return
|
||||||
|
|
||||||
case "tool-input-delta":
|
case "tool-input-delta":
|
||||||
|
// AI SDK emits a final `tool-call` with the parsed `input`; accumulating
|
||||||
|
// delta fragments into `state.raw` is redundant work for no current consumer.
|
||||||
return
|
return
|
||||||
|
|
||||||
case "tool-input-end": {
|
case "tool-input-end": {
|
||||||
|
const toolCall = yield* ensureToolCall(value)
|
||||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||||
if (flags.experimentalEventSystem) {
|
if (flags.experimentalEventSystem) {
|
||||||
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
||||||
@@ -315,35 +369,50 @@ export const layer = Layer.effect(
|
|||||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
ctx.toolcalls[value.id] = { ...toolCall.call, inputEnded: true }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
case "tool-call": {
|
case "tool-call": {
|
||||||
if (ctx.assistantMessage.summary) {
|
if (ctx.assistantMessage.summary) {
|
||||||
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
throw new Error(`Tool call not allowed while generating summary: ${value.name}`)
|
||||||
|
}
|
||||||
|
const toolCall = yield* ensureToolCall(value)
|
||||||
|
const input = toolInput(value.input)
|
||||||
|
if (!toolCall.call.inputEnded) {
|
||||||
|
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||||
|
if (flags.experimentalEventSystem) {
|
||||||
|
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
||||||
|
sessionID: ctx.sessionID,
|
||||||
|
callID: value.id,
|
||||||
|
text: "",
|
||||||
|
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const toolCall = yield* readToolCall(value.toolCallId)
|
|
||||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||||
if (flags.experimentalEventSystem) {
|
if (flags.experimentalEventSystem) {
|
||||||
yield* events.publish(SessionEvent.Tool.Called, {
|
yield* events.publish(SessionEvent.Tool.Called, {
|
||||||
sessionID: ctx.sessionID,
|
sessionID: ctx.sessionID,
|
||||||
callID: value.toolCallId,
|
callID: value.id,
|
||||||
tool: value.toolName,
|
tool: value.name,
|
||||||
input: value.input,
|
input,
|
||||||
provider: {
|
provider: {
|
||||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
executed: toolCall.part.metadata?.providerExecuted === true,
|
||||||
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
|
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
|
||||||
},
|
},
|
||||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
yield* updateToolCall(value.toolCallId, (match) => ({
|
yield* updateToolCall(value.id, (match) => ({
|
||||||
...match,
|
...match,
|
||||||
tool: value.toolName,
|
tool: value.name,
|
||||||
state: {
|
state:
|
||||||
...match.state,
|
match.state.status === "running"
|
||||||
|
? { ...match.state, input }
|
||||||
|
: {
|
||||||
status: "running",
|
status: "running",
|
||||||
input: value.input,
|
input,
|
||||||
time: { start: Date.now() },
|
time: { start: Date.now() },
|
||||||
},
|
},
|
||||||
metadata: match.metadata?.providerExecuted
|
metadata: match.metadata?.providerExecuted
|
||||||
@@ -359,9 +428,9 @@ export const layer = Layer.effect(
|
|||||||
!recentParts.every(
|
!recentParts.every(
|
||||||
(part) =>
|
(part) =>
|
||||||
part.type === "tool" &&
|
part.type === "tool" &&
|
||||||
part.tool === value.toolName &&
|
part.tool === value.name &&
|
||||||
part.state.status !== "pending" &&
|
part.state.status !== "pending" &&
|
||||||
JSON.stringify(part.state.input) === JSON.stringify(value.input),
|
JSON.stringify(part.state.input) === JSON.stringify(input),
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
@@ -370,27 +439,19 @@ export const layer = Layer.effect(
|
|||||||
const agent = yield* agents.get(ctx.assistantMessage.agent)
|
const agent = yield* agents.get(ctx.assistantMessage.agent)
|
||||||
yield* permission.ask({
|
yield* permission.ask({
|
||||||
permission: "doom_loop",
|
permission: "doom_loop",
|
||||||
patterns: [value.toolName],
|
patterns: [value.name],
|
||||||
sessionID: ctx.assistantMessage.sessionID,
|
sessionID: ctx.assistantMessage.sessionID,
|
||||||
metadata: { tool: value.toolName, input: value.input },
|
metadata: { tool: value.name, input },
|
||||||
always: [value.toolName],
|
always: [value.name],
|
||||||
ruleset: agent.permission,
|
ruleset: agent.permission,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
case "tool-result": {
|
case "tool-result": {
|
||||||
const toolCall = yield* readToolCall(value.toolCallId)
|
const toolCall = yield* readToolCall(value.id)
|
||||||
const toolAttachments: MessageV2.FilePart[] = (
|
const rawOutput = toolResultOutput(value)
|
||||||
Array.isArray(value.output.attachments) ? value.output.attachments : []
|
const normalized = yield* Effect.forEach(rawOutput.attachments ?? [], (attachment) =>
|
||||||
).filter(
|
|
||||||
(attachment: unknown): attachment is MessageV2.FilePart =>
|
|
||||||
isRecord(attachment) &&
|
|
||||||
attachment.type === "file" &&
|
|
||||||
typeof attachment.mime === "string" &&
|
|
||||||
typeof attachment.url === "string",
|
|
||||||
)
|
|
||||||
const normalized = yield* Effect.forEach(toolAttachments, (attachment) =>
|
|
||||||
attachment.mime.startsWith("image/")
|
attachment.mime.startsWith("image/")
|
||||||
? image.normalize(attachment).pipe(
|
? image.normalize(attachment).pipe(
|
||||||
Effect.catchIf(
|
Effect.catchIf(
|
||||||
@@ -404,18 +465,18 @@ export const layer = Layer.effect(
|
|||||||
const omitted = normalized.filter(Exit.isFailure).length
|
const omitted = normalized.filter(Exit.isFailure).length
|
||||||
const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value)
|
const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value)
|
||||||
const output = {
|
const output = {
|
||||||
...value.output,
|
...rawOutput,
|
||||||
output:
|
output:
|
||||||
omitted === 0
|
omitted === 0
|
||||||
? value.output.output
|
? rawOutput.output
|
||||||
: `${value.output.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the image size limit.]`,
|
: `${rawOutput.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the image size limit.]`,
|
||||||
attachments: attachments?.length ? attachments : undefined,
|
attachments: attachments.length ? attachments : undefined,
|
||||||
}
|
}
|
||||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||||
if (flags.experimentalEventSystem) {
|
if (flags.experimentalEventSystem) {
|
||||||
yield* events.publish(SessionEvent.Tool.Success, {
|
yield* events.publish(SessionEvent.Tool.Success, {
|
||||||
sessionID: ctx.sessionID,
|
sessionID: ctx.sessionID,
|
||||||
callID: value.toolCallId,
|
callID: value.id,
|
||||||
structured: output.metadata,
|
structured: output.metadata,
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@@ -423,32 +484,32 @@ export const layer = Layer.effect(
|
|||||||
text: output.output,
|
text: output.output,
|
||||||
},
|
},
|
||||||
...(output.attachments?.map((item: MessageV2.FilePart) => ({
|
...(output.attachments?.map((item: MessageV2.FilePart) => ({
|
||||||
type: "file",
|
type: "file" as const,
|
||||||
uri: item.url,
|
uri: item.url,
|
||||||
mime: item.mime,
|
mime: item.mime,
|
||||||
name: item.filename,
|
name: item.filename,
|
||||||
})) ?? []),
|
})) ?? []),
|
||||||
],
|
],
|
||||||
provider: {
|
provider: {
|
||||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,
|
||||||
},
|
},
|
||||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
yield* completeToolCall(value.toolCallId, output)
|
yield* completeToolCall(value.id, output)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
case "tool-error": {
|
case "tool-error": {
|
||||||
const toolCall = yield* readToolCall(value.toolCallId)
|
const toolCall = yield* readToolCall(value.id)
|
||||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||||
if (flags.experimentalEventSystem) {
|
if (flags.experimentalEventSystem) {
|
||||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||||
sessionID: ctx.sessionID,
|
sessionID: ctx.sessionID,
|
||||||
callID: value.toolCallId,
|
callID: value.id,
|
||||||
error: {
|
error: {
|
||||||
type: "unknown",
|
type: "unknown",
|
||||||
message: errorMessage(value.error),
|
message: value.message,
|
||||||
},
|
},
|
||||||
provider: {
|
provider: {
|
||||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
executed: toolCall?.part.metadata?.providerExecuted === true,
|
||||||
@@ -456,14 +517,14 @@ export const layer = Layer.effect(
|
|||||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
yield* failToolCall(value.toolCallId, value.error)
|
yield* failToolCall(value.id, value.error ?? new Error(value.message))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
case "error":
|
case "provider-error":
|
||||||
throw value.error
|
throw new Error(value.message)
|
||||||
|
|
||||||
case "start-step":
|
case "step-start":
|
||||||
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
|
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
|
||||||
if (!ctx.assistantMessage.summary) {
|
if (!ctx.assistantMessage.summary) {
|
||||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||||
@@ -490,11 +551,12 @@ export const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
||||||
case "finish-step": {
|
case "step-finish": {
|
||||||
const completedSnapshot = yield* snapshot.track()
|
const completedSnapshot = yield* snapshot.track()
|
||||||
|
yield* Effect.forEach(Object.keys(ctx.reasoningMap), finishReasoning)
|
||||||
const usage = Session.getUsage({
|
const usage = Session.getUsage({
|
||||||
model: ctx.model,
|
model: ctx.model,
|
||||||
usage: value.usage,
|
usage: value.usage ?? new Usage({}),
|
||||||
metadata: value.providerMetadata,
|
metadata: value.providerMetadata,
|
||||||
})
|
})
|
||||||
if (!ctx.assistantMessage.summary) {
|
if (!ctx.assistantMessage.summary) {
|
||||||
@@ -502,7 +564,7 @@ export const layer = Layer.effect(
|
|||||||
if (flags.experimentalEventSystem) {
|
if (flags.experimentalEventSystem) {
|
||||||
yield* events.publish(SessionEvent.Step.Ended, {
|
yield* events.publish(SessionEvent.Step.Ended, {
|
||||||
sessionID: ctx.sessionID,
|
sessionID: ctx.sessionID,
|
||||||
finish: value.finishReason,
|
finish: value.reason,
|
||||||
cost: usage.cost,
|
cost: usage.cost,
|
||||||
tokens: usage.tokens,
|
tokens: usage.tokens,
|
||||||
snapshot: completedSnapshot,
|
snapshot: completedSnapshot,
|
||||||
@@ -510,12 +572,12 @@ export const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.assistantMessage.finish = value.finishReason
|
ctx.assistantMessage.finish = value.reason
|
||||||
ctx.assistantMessage.cost += usage.cost
|
ctx.assistantMessage.cost += usage.cost
|
||||||
ctx.assistantMessage.tokens = usage.tokens
|
ctx.assistantMessage.tokens = usage.tokens
|
||||||
yield* session.updatePart({
|
yield* session.updatePart({
|
||||||
id: PartID.ascending(),
|
id: PartID.ascending(),
|
||||||
reason: value.finishReason,
|
reason: value.reason,
|
||||||
snapshot: completedSnapshot,
|
snapshot: completedSnapshot,
|
||||||
messageID: ctx.assistantMessage.id,
|
messageID: ctx.assistantMessage.id,
|
||||||
sessionID: ctx.assistantMessage.sessionID,
|
sessionID: ctx.assistantMessage.sessionID,
|
||||||
@@ -622,10 +684,6 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
case "finish":
|
case "finish":
|
||||||
return
|
return
|
||||||
|
|
||||||
default:
|
|
||||||
slog.info("unhandled", { event: value.type, value })
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -727,6 +785,7 @@ export const layer = Layer.effect(
|
|||||||
yield* Effect.gen(function* () {
|
yield* Effect.gen(function* () {
|
||||||
ctx.currentText = undefined
|
ctx.currentText = undefined
|
||||||
ctx.reasoningMap = {}
|
ctx.reasoningMap = {}
|
||||||
|
yield* status.set(ctx.sessionID, { type: "busy" })
|
||||||
const stream = llm.stream(streamInput)
|
const stream = llm.stream(streamInput)
|
||||||
|
|
||||||
yield* stream.pipe(
|
yield* stream.pipe(
|
||||||
|
|||||||
@@ -8,19 +8,15 @@ import * as Session from "./session"
|
|||||||
import { Agent } from "../agent/agent"
|
import { Agent } from "../agent/agent"
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import { ModelID, ProviderID } from "../provider/schema"
|
import { ModelID, ProviderID } from "../provider/schema"
|
||||||
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
|
import { type Tool as AITool, tool, jsonSchema } from "ai"
|
||||||
import type { JSONSchema7 } from "@ai-sdk/provider"
|
import type { JSONSchema7 } from "@ai-sdk/provider"
|
||||||
import { SessionCompaction } from "./compaction"
|
import { SessionCompaction } from "./compaction"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { ProviderTransform } from "@/provider/transform"
|
|
||||||
import { SystemPrompt } from "./system"
|
import { SystemPrompt } from "./system"
|
||||||
import { Instruction } from "./instruction"
|
import { Instruction } from "./instruction"
|
||||||
import { Plugin } from "../plugin"
|
import { Plugin } from "../plugin"
|
||||||
import PROMPT_PLAN from "../session/prompt/plan.txt"
|
|
||||||
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
|
|
||||||
import MAX_STEPS from "../session/prompt/max-steps.txt"
|
import MAX_STEPS from "../session/prompt/max-steps.txt"
|
||||||
import { ToolRegistry } from "@/tool/registry"
|
import { ToolRegistry } from "@/tool/registry"
|
||||||
import { ToolJsonSchema } from "@/tool/json-schema"
|
|
||||||
import { MCP } from "../mcp"
|
import { MCP } from "../mcp"
|
||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
import { ulid } from "ulid"
|
import { ulid } from "ulid"
|
||||||
@@ -50,7 +46,6 @@ import * as EffectLogger from "@opencode-ai/core/effect/logger"
|
|||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { TaskTool, type TaskPromptOps } from "@/tool/task"
|
import { TaskTool, type TaskPromptOps } from "@/tool/task"
|
||||||
import { SessionRunState } from "./run-state"
|
import { SessionRunState } from "./run-state"
|
||||||
import { EffectBridge } from "@/effect/bridge"
|
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||||
@@ -63,6 +58,10 @@ import * as DateTime from "effect/DateTime"
|
|||||||
import { eq } from "@/storage/db"
|
import { eq } from "@/storage/db"
|
||||||
import * as Database from "@/storage/db"
|
import * as Database from "@/storage/db"
|
||||||
import { SessionTable } from "./session.sql"
|
import { SessionTable } from "./session.sql"
|
||||||
|
import { referencePromptMetadata, referenceTextPart } from "./prompt/reference"
|
||||||
|
import { SessionReminders } from "./reminders"
|
||||||
|
import { SessionTools } from "./tools"
|
||||||
|
import { LLMEvent } from "@opencode-ai/llm"
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||||
@@ -83,88 +82,6 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc
|
|||||||
const log = Log.create({ service: "session.prompt" })
|
const log = Log.create({ service: "session.prompt" })
|
||||||
const elog = EffectLogger.create({ service: "session.prompt" })
|
const elog = EffectLogger.create({ service: "session.prompt" })
|
||||||
|
|
||||||
type ReferencePromptMetadata = {
|
|
||||||
name: string
|
|
||||||
kind: "local" | "git" | "invalid"
|
|
||||||
path?: string
|
|
||||||
repository?: string
|
|
||||||
branch?: string
|
|
||||||
target?: string
|
|
||||||
targetPath?: string
|
|
||||||
problem?: string
|
|
||||||
source: { value: string; start: number; end: number }
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringField(record: Record<string, unknown>, key: string) {
|
|
||||||
return typeof record[key] === "string" ? record[key] : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function referencePromptMetadata(input: unknown): ReferencePromptMetadata | undefined {
|
|
||||||
if (!input || typeof input !== "object" || Array.isArray(input)) return
|
|
||||||
const record = input as Record<string, unknown>
|
|
||||||
const name = stringField(record, "name")
|
|
||||||
const kind = stringField(record, "kind")
|
|
||||||
if (!name || (kind !== "local" && kind !== "git" && kind !== "invalid")) return
|
|
||||||
if (!record.source || typeof record.source !== "object" || Array.isArray(record.source)) return
|
|
||||||
const source = record.source as Record<string, unknown>
|
|
||||||
const value = stringField(source, "value")
|
|
||||||
if (!value || typeof source.start !== "number" || typeof source.end !== "number") return
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
kind,
|
|
||||||
path: stringField(record, "path"),
|
|
||||||
repository: stringField(record, "repository"),
|
|
||||||
branch: stringField(record, "branch"),
|
|
||||||
target: stringField(record, "target"),
|
|
||||||
targetPath: stringField(record, "targetPath"),
|
|
||||||
problem: stringField(record, "problem"),
|
|
||||||
source: { value, start: source.start, end: source.end },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function referenceTextPart(input: {
|
|
||||||
reference: Reference.Resolved
|
|
||||||
source: ReferencePromptMetadata["source"]
|
|
||||||
target?: string
|
|
||||||
targetPath?: string
|
|
||||||
problem?: string
|
|
||||||
}): MessageV2.TextPartInput {
|
|
||||||
const metadata: ReferencePromptMetadata = {
|
|
||||||
name: input.reference.name,
|
|
||||||
kind: input.reference.kind,
|
|
||||||
...(input.reference.kind === "invalid"
|
|
||||||
? { repository: input.reference.repository }
|
|
||||||
: { path: input.reference.path }),
|
|
||||||
...(input.reference.kind === "git"
|
|
||||||
? { repository: input.reference.repository, branch: input.reference.branch }
|
|
||||||
: {}),
|
|
||||||
...(input.target === undefined ? {} : { target: input.target }),
|
|
||||||
...(input.targetPath ? { targetPath: input.targetPath } : {}),
|
|
||||||
problem: input.problem ?? (input.reference.kind === "invalid" ? input.reference.message : undefined),
|
|
||||||
source: input.source,
|
|
||||||
}
|
|
||||||
const label = metadata.target === undefined ? `@${metadata.name}` : `@${metadata.name}/${metadata.target}`
|
|
||||||
return {
|
|
||||||
type: "text",
|
|
||||||
synthetic: true,
|
|
||||||
text: [
|
|
||||||
`Referenced configured reference ${label}.`,
|
|
||||||
...(metadata.kind === "local" ? ["Kind: local directory"] : []),
|
|
||||||
...(metadata.kind === "git" ? ["Kind: git repository"] : []),
|
|
||||||
...(metadata.repository ? [`Repository: ${metadata.repository}`] : []),
|
|
||||||
...(metadata.branch ? [`Branch/ref: ${metadata.branch}`] : []),
|
|
||||||
...(metadata.path ? [`Reference root: ${metadata.path}`] : []),
|
|
||||||
...(metadata.targetPath ? [`Resolved path: ${metadata.targetPath}`] : []),
|
|
||||||
...(metadata.problem
|
|
||||||
? [`Problem: ${metadata.problem}`]
|
|
||||||
: [
|
|
||||||
"For targeted context, inspect the reference path directly with Read, Glob, and Grep. For broader research, call the task tool with subagent scout and include this reference path.",
|
|
||||||
]),
|
|
||||||
].join("\n"),
|
|
||||||
metadata: { reference: metadata },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
|
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
|
||||||
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error>
|
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error>
|
||||||
@@ -207,9 +124,6 @@ export const layer = Layer.effect(
|
|||||||
const references = yield* Reference.Service
|
const references = yield* Reference.Service
|
||||||
const events = yield* EventV2Bridge.Service
|
const events = yield* EventV2Bridge.Service
|
||||||
const flags = yield* RuntimeFlags.Service
|
const flags = yield* RuntimeFlags.Service
|
||||||
const runner = Effect.fn("SessionPrompt.runner")(function* () {
|
|
||||||
return yield* EffectBridge.make()
|
|
||||||
})
|
|
||||||
const ops = Effect.fn("SessionPrompt.ops")(function* () {
|
const ops = Effect.fn("SessionPrompt.ops")(function* () {
|
||||||
return {
|
return {
|
||||||
cancel: (sessionID: SessionID) => cancel(sessionID),
|
cancel: (sessionID: SessionID) => cancel(sessionID),
|
||||||
@@ -365,7 +279,7 @@ export const layer = Layer.effect(
|
|||||||
messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs],
|
messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs],
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
Stream.filter((e): e is Extract<LLM.Event, { type: "text-delta" }> => e.type === "text-delta"),
|
Stream.filter(LLMEvent.is.textDelta),
|
||||||
Stream.map((e) => e.text),
|
Stream.map((e) => e.text),
|
||||||
Stream.mkString,
|
Stream.mkString,
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
@@ -382,323 +296,6 @@ export const layer = Layer.effect(
|
|||||||
.pipe(Effect.catchCause((cause) => elog.error("failed to generate title", { error: Cause.squash(cause) })))
|
.pipe(Effect.catchCause((cause) => elog.error("failed to generate title", { error: Cause.squash(cause) })))
|
||||||
})
|
})
|
||||||
|
|
||||||
const insertReminders = Effect.fn("SessionPrompt.insertReminders")(function* (input: {
|
|
||||||
messages: MessageV2.WithParts[]
|
|
||||||
agent: Agent.Info
|
|
||||||
session: Session.Info
|
|
||||||
}) {
|
|
||||||
const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
|
|
||||||
if (!userMessage) return input.messages
|
|
||||||
|
|
||||||
if (!flags.experimentalPlanMode) {
|
|
||||||
if (input.agent.name === "plan") {
|
|
||||||
userMessage.parts.push({
|
|
||||||
id: PartID.ascending(),
|
|
||||||
messageID: userMessage.info.id,
|
|
||||||
sessionID: userMessage.info.sessionID,
|
|
||||||
type: "text",
|
|
||||||
text: PROMPT_PLAN,
|
|
||||||
synthetic: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
|
|
||||||
if (wasPlan && input.agent.name === "build") {
|
|
||||||
userMessage.parts.push({
|
|
||||||
id: PartID.ascending(),
|
|
||||||
messageID: userMessage.info.id,
|
|
||||||
sessionID: userMessage.info.sessionID,
|
|
||||||
type: "text",
|
|
||||||
text: BUILD_SWITCH,
|
|
||||||
synthetic: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return input.messages
|
|
||||||
}
|
|
||||||
|
|
||||||
const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant")
|
|
||||||
if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") {
|
|
||||||
const ctx = yield* InstanceState.context
|
|
||||||
const plan = Session.plan(input.session, ctx)
|
|
||||||
if (!(yield* fsys.existsSafe(plan))) return input.messages
|
|
||||||
const part = yield* sessions.updatePart({
|
|
||||||
id: PartID.ascending(),
|
|
||||||
messageID: userMessage.info.id,
|
|
||||||
sessionID: userMessage.info.sessionID,
|
|
||||||
type: "text",
|
|
||||||
text: `${BUILD_SWITCH}\n\nA plan file exists at ${plan}. You should execute on the plan defined within it`,
|
|
||||||
synthetic: true,
|
|
||||||
})
|
|
||||||
userMessage.parts.push(part)
|
|
||||||
return input.messages
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.agent.name !== "plan" || assistantMessage?.info.agent === "plan") return input.messages
|
|
||||||
|
|
||||||
const ctx = yield* InstanceState.context
|
|
||||||
const plan = Session.plan(input.session, ctx)
|
|
||||||
const exists = yield* fsys.existsSafe(plan)
|
|
||||||
if (!exists) yield* fsys.ensureDir(path.dirname(plan)).pipe(Effect.catch(Effect.die))
|
|
||||||
const part = yield* sessions.updatePart({
|
|
||||||
id: PartID.ascending(),
|
|
||||||
messageID: userMessage.info.id,
|
|
||||||
sessionID: userMessage.info.sessionID,
|
|
||||||
type: "text",
|
|
||||||
text: `<system-reminder>
|
|
||||||
Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received.
|
|
||||||
|
|
||||||
## Plan File Info:
|
|
||||||
${exists ? `A plan file already exists at ${plan}. You can read it and make incremental edits using the edit tool.` : `No plan file exists yet. You should create your plan at ${plan} using the write tool.`}
|
|
||||||
You should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions.
|
|
||||||
|
|
||||||
## Plan Workflow
|
|
||||||
|
|
||||||
### Phase 1: Initial Understanding
|
|
||||||
Goal: Gain a comprehensive understanding of the user's request by reading through code and asking them questions. Critical: In this phase you should only use the explore subagent type.
|
|
||||||
|
|
||||||
1. Focus on understanding the user's request and the code associated with their request
|
|
||||||
|
|
||||||
2. **Launch up to 3 explore agents IN PARALLEL** (single message, multiple tool calls) to efficiently explore the codebase.
|
|
||||||
- Use 1 agent when the task is isolated to known files, the user provided specific file paths, or you're making a small targeted change.
|
|
||||||
- Use multiple agents when: the scope is uncertain, multiple areas of the codebase are involved, or you need to understand existing patterns before planning.
|
|
||||||
- Quality over quantity - 3 agents maximum, but you should try to use the minimum number of agents necessary (usually just 1)
|
|
||||||
- If using multiple agents: Provide each agent with a specific search focus or area to explore. Example: One agent searches for existing implementations, another explores related components, a third investigates testing patterns
|
|
||||||
|
|
||||||
3. After exploring the code, use the question tool to clarify ambiguities in the user request up front.
|
|
||||||
|
|
||||||
### Phase 2: Design
|
|
||||||
Goal: Design an implementation approach.
|
|
||||||
|
|
||||||
Launch general agent(s) to design the implementation based on the user's intent and your exploration results from Phase 1.
|
|
||||||
|
|
||||||
You can launch up to 1 agent(s) in parallel.
|
|
||||||
|
|
||||||
**Guidelines:**
|
|
||||||
- **Default**: Launch at least 1 Plan agent for most tasks - it helps validate your understanding and consider alternatives
|
|
||||||
- **Skip agents**: Only for truly trivial tasks (typo fixes, single-line changes, simple renames)
|
|
||||||
|
|
||||||
Examples of when to use multiple agents:
|
|
||||||
- The task touches multiple parts of the codebase
|
|
||||||
- It's a large refactor or architectural change
|
|
||||||
- There are many edge cases to consider
|
|
||||||
- You'd benefit from exploring different approaches
|
|
||||||
|
|
||||||
Example perspectives by task type:
|
|
||||||
- New feature: simplicity vs performance vs maintainability
|
|
||||||
- Bug fix: root cause vs workaround vs prevention
|
|
||||||
- Refactoring: minimal change vs clean architecture
|
|
||||||
|
|
||||||
In the agent prompt:
|
|
||||||
- Provide comprehensive background context from Phase 1 exploration including filenames and code path traces
|
|
||||||
- Describe requirements and constraints
|
|
||||||
- Request a detailed implementation plan
|
|
||||||
|
|
||||||
### Phase 3: Review
|
|
||||||
Goal: Review the plan(s) from Phase 2 and ensure alignment with the user's intentions.
|
|
||||||
1. Read the critical files identified by agents to deepen your understanding
|
|
||||||
2. Ensure that the plans align with the user's original request
|
|
||||||
3. Use question tool to clarify any remaining questions with the user
|
|
||||||
|
|
||||||
### Phase 4: Final Plan
|
|
||||||
Goal: Write your final plan to the plan file (the only file you can edit).
|
|
||||||
- Include only your recommended approach, not all alternatives
|
|
||||||
- Ensure that the plan file is concise enough to scan quickly, but detailed enough to execute effectively
|
|
||||||
- Include the paths of critical files to be modified
|
|
||||||
- Include a verification section describing how to test the changes end-to-end (run the code, use MCP tools, run tests)
|
|
||||||
|
|
||||||
### Phase 5: Call plan_exit tool
|
|
||||||
At the very end of your turn, once you have asked the user questions and are happy with your final plan file - you should always call plan_exit to indicate to the user that you are done planning.
|
|
||||||
This is critical - your turn should only end with either asking the user a question or calling plan_exit. Do not stop unless it's for these 2 reasons.
|
|
||||||
|
|
||||||
**Important:** Use question tool to clarify requirements/approach, use plan_exit to request plan approval. Do NOT use question tool to ask "Is this plan okay?" - that's what plan_exit does.
|
|
||||||
|
|
||||||
NOTE: At any point in time through this workflow you should feel free to ask the user questions or clarifications. Don't make large assumptions about user intent. The goal is to present a well researched plan to the user, and tie any loose ends before implementation begins.
|
|
||||||
</system-reminder>`,
|
|
||||||
synthetic: true,
|
|
||||||
})
|
|
||||||
userMessage.parts.push(part)
|
|
||||||
return input.messages
|
|
||||||
})
|
|
||||||
|
|
||||||
const resolveTools = Effect.fn("SessionPrompt.resolveTools")(function* (input: {
|
|
||||||
agent: Agent.Info
|
|
||||||
model: Provider.Model
|
|
||||||
session: Session.Info
|
|
||||||
tools?: Record<string, boolean>
|
|
||||||
processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">
|
|
||||||
bypassAgentCheck: boolean
|
|
||||||
messages: MessageV2.WithParts[]
|
|
||||||
}) {
|
|
||||||
using _ = log.time("resolveTools")
|
|
||||||
const tools: Record<string, AITool> = {}
|
|
||||||
const run = yield* runner()
|
|
||||||
const promptOps = yield* ops()
|
|
||||||
|
|
||||||
const context = (args: any, options: ToolExecutionOptions): Tool.Context => ({
|
|
||||||
sessionID: input.session.id,
|
|
||||||
abort: options.abortSignal!,
|
|
||||||
messageID: input.processor.message.id,
|
|
||||||
callID: options.toolCallId,
|
|
||||||
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps },
|
|
||||||
agent: input.agent.name,
|
|
||||||
messages: input.messages,
|
|
||||||
metadata: (val) =>
|
|
||||||
input.processor.updateToolCall(options.toolCallId, (match) => {
|
|
||||||
if (!["running", "pending"].includes(match.state.status)) return match
|
|
||||||
return {
|
|
||||||
...match,
|
|
||||||
state: {
|
|
||||||
title: val.title,
|
|
||||||
metadata: val.metadata,
|
|
||||||
status: "running",
|
|
||||||
input: args,
|
|
||||||
time: { start: Date.now() },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
ask: (req) =>
|
|
||||||
permission
|
|
||||||
.ask({
|
|
||||||
...req,
|
|
||||||
sessionID: input.session.id,
|
|
||||||
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
|
|
||||||
ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
|
|
||||||
})
|
|
||||||
.pipe(Effect.orDie),
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const item of yield* registry.tools({
|
|
||||||
modelID: ModelID.make(input.model.api.id),
|
|
||||||
providerID: input.model.providerID,
|
|
||||||
agent: input.agent,
|
|
||||||
})) {
|
|
||||||
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
|
|
||||||
tools[item.id] = tool({
|
|
||||||
description: item.description,
|
|
||||||
inputSchema: jsonSchema(schema),
|
|
||||||
execute(args, options) {
|
|
||||||
return run.promise(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const ctx = context(args, options)
|
|
||||||
yield* plugin.trigger(
|
|
||||||
"tool.execute.before",
|
|
||||||
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
|
|
||||||
{ args },
|
|
||||||
)
|
|
||||||
const result = yield* item.execute(args, ctx)
|
|
||||||
const output = {
|
|
||||||
...result,
|
|
||||||
attachments: result.attachments?.map((attachment) => ({
|
|
||||||
...attachment,
|
|
||||||
id: PartID.ascending(),
|
|
||||||
sessionID: ctx.sessionID,
|
|
||||||
messageID: input.processor.message.id,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
yield* plugin.trigger(
|
|
||||||
"tool.execute.after",
|
|
||||||
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },
|
|
||||||
output,
|
|
||||||
)
|
|
||||||
if (options.abortSignal?.aborted) {
|
|
||||||
yield* input.processor.completeToolCall(options.toolCallId, output)
|
|
||||||
}
|
|
||||||
return output
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, item] of Object.entries(yield* mcp.tools())) {
|
|
||||||
const execute = item.execute
|
|
||||||
if (!execute) continue
|
|
||||||
|
|
||||||
const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))
|
|
||||||
const transformed = ProviderTransform.schema(input.model, schema)
|
|
||||||
item.inputSchema = jsonSchema(transformed)
|
|
||||||
item.execute = (args, opts) =>
|
|
||||||
run.promise(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const ctx = context(args, opts)
|
|
||||||
yield* plugin.trigger(
|
|
||||||
"tool.execute.before",
|
|
||||||
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
|
||||||
{ args },
|
|
||||||
)
|
|
||||||
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () {
|
|
||||||
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
|
|
||||||
return yield* Effect.promise(() => execute(args, opts))
|
|
||||||
}).pipe(
|
|
||||||
Effect.withSpan("Tool.execute", {
|
|
||||||
attributes: {
|
|
||||||
"tool.name": key,
|
|
||||||
"tool.call_id": opts.toolCallId,
|
|
||||||
"session.id": ctx.sessionID,
|
|
||||||
"message.id": input.processor.message.id,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
yield* plugin.trigger(
|
|
||||||
"tool.execute.after",
|
|
||||||
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
|
|
||||||
const textParts: string[] = []
|
|
||||||
const attachments: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[] = []
|
|
||||||
for (const contentItem of result.content) {
|
|
||||||
if (contentItem.type === "text") textParts.push(contentItem.text)
|
|
||||||
else if (contentItem.type === "image") {
|
|
||||||
attachments.push({
|
|
||||||
type: "file",
|
|
||||||
mime: contentItem.mimeType,
|
|
||||||
url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
|
|
||||||
})
|
|
||||||
} else if (contentItem.type === "resource") {
|
|
||||||
const { resource } = contentItem
|
|
||||||
if (resource.text) textParts.push(resource.text)
|
|
||||||
if (resource.blob) {
|
|
||||||
attachments.push({
|
|
||||||
type: "file",
|
|
||||||
mime: resource.mimeType ?? "application/octet-stream",
|
|
||||||
url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
|
|
||||||
filename: resource.uri,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent)
|
|
||||||
const metadata = {
|
|
||||||
...result.metadata,
|
|
||||||
truncated: truncated.truncated,
|
|
||||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = {
|
|
||||||
title: "",
|
|
||||||
metadata,
|
|
||||||
output: truncated.content,
|
|
||||||
attachments: attachments.map((attachment) => ({
|
|
||||||
...attachment,
|
|
||||||
id: PartID.ascending(),
|
|
||||||
sessionID: ctx.sessionID,
|
|
||||||
messageID: input.processor.message.id,
|
|
||||||
})),
|
|
||||||
content: result.content,
|
|
||||||
}
|
|
||||||
if (opts.abortSignal?.aborted) {
|
|
||||||
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
|
||||||
}
|
|
||||||
return output
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
tools[key] = item
|
|
||||||
}
|
|
||||||
|
|
||||||
return tools
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleSubtask = Effect.fn("SessionPrompt.handleSubtask")(function* (input: {
|
const handleSubtask = Effect.fn("SessionPrompt.handleSubtask")(function* (input: {
|
||||||
task: MessageV2.SubtaskPart
|
task: MessageV2.SubtaskPart
|
||||||
model: Provider.Model
|
model: Provider.Model
|
||||||
@@ -1726,7 +1323,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
}
|
}
|
||||||
const maxSteps = agent.steps ?? Infinity
|
const maxSteps = agent.steps ?? Infinity
|
||||||
const isLastStep = step >= maxSteps
|
const isLastStep = step >= maxSteps
|
||||||
msgs = yield* insertReminders({ messages: msgs, agent, session })
|
msgs = yield* SessionReminders.apply({ messages: msgs, agent, session }).pipe(
|
||||||
|
Effect.provideService(RuntimeFlags.Service, flags),
|
||||||
|
Effect.provideService(AppFileSystem.Service, fsys),
|
||||||
|
Effect.provideService(Session.Service, sessions),
|
||||||
|
)
|
||||||
|
|
||||||
const msg: MessageV2.Assistant = {
|
const msg: MessageV2.Assistant = {
|
||||||
id: MessageID.ascending(),
|
id: MessageID.ascending(),
|
||||||
@@ -1766,16 +1367,23 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
const outcome: "break" | "continue" = yield* Effect.gen(function* () {
|
const outcome: "break" | "continue" = yield* Effect.gen(function* () {
|
||||||
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
|
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
|
||||||
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
|
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
|
||||||
|
const promptOps = yield* ops()
|
||||||
|
|
||||||
const tools = yield* resolveTools({
|
const tools = yield* SessionTools.resolve({
|
||||||
agent,
|
agent,
|
||||||
session,
|
session,
|
||||||
model,
|
model,
|
||||||
tools: lastUser.tools,
|
|
||||||
processor: handle,
|
processor: handle,
|
||||||
bypassAgentCheck,
|
bypassAgentCheck,
|
||||||
messages: msgs,
|
messages: msgs,
|
||||||
})
|
promptOps,
|
||||||
|
}).pipe(
|
||||||
|
Effect.provideService(Plugin.Service, plugin),
|
||||||
|
Effect.provideService(Permission.Service, permission),
|
||||||
|
Effect.provideService(ToolRegistry.Service, registry),
|
||||||
|
Effect.provideService(MCP.Service, mcp),
|
||||||
|
Effect.provideService(Truncate.Service, truncate),
|
||||||
|
)
|
||||||
|
|
||||||
if (lastUser.format?.type === "json_schema") {
|
if (lastUser.format?.type === "json_schema") {
|
||||||
tools["StructuredOutput"] = createStructuredOutputTool({
|
tools["StructuredOutput"] = createStructuredOutputTool({
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<system-reminder>
|
||||||
|
Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received.
|
||||||
|
|
||||||
|
## Plan File Info:
|
||||||
|
${planInfo}
|
||||||
|
You should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions.
|
||||||
|
|
||||||
|
## Plan Workflow
|
||||||
|
|
||||||
|
### Phase 1: Initial Understanding
|
||||||
|
Goal: Gain a comprehensive understanding of the user's request by reading through code and asking them questions. Critical: In this phase you should only use the explore subagent type.
|
||||||
|
|
||||||
|
1. Focus on understanding the user's request and the code associated with their request
|
||||||
|
|
||||||
|
2. **Launch up to 3 explore agents IN PARALLEL** (single message, multiple tool calls) to efficiently explore the codebase.
|
||||||
|
- Use 1 agent when the task is isolated to known files, the user provided specific file paths, or you're making a small targeted change.
|
||||||
|
- Use multiple agents when: the scope is uncertain, multiple areas of the codebase are involved, or you need to understand existing patterns before planning.
|
||||||
|
- Quality over quantity - 3 agents maximum, but you should try to use the minimum number of agents necessary (usually just 1)
|
||||||
|
- If using multiple agents: Provide each agent with a specific search focus or area to explore. Example: One agent searches for existing implementations, another explores related components, a third investigates testing patterns
|
||||||
|
|
||||||
|
3. After exploring the code, use the question tool to clarify ambiguities in the user request up front.
|
||||||
|
|
||||||
|
### Phase 2: Design
|
||||||
|
Goal: Design an implementation approach.
|
||||||
|
|
||||||
|
Launch general agent(s) to design the implementation based on the user's intent and your exploration results from Phase 1.
|
||||||
|
|
||||||
|
You can launch up to 1 agent(s) in parallel.
|
||||||
|
|
||||||
|
**Guidelines:**
|
||||||
|
- **Default**: Launch at least 1 Plan agent for most tasks - it helps validate your understanding and consider alternatives
|
||||||
|
- **Skip agents**: Only for truly trivial tasks (typo fixes, single-line changes, simple renames)
|
||||||
|
|
||||||
|
Examples of when to use multiple agents:
|
||||||
|
- The task touches multiple parts of the codebase
|
||||||
|
- It's a large refactor or architectural change
|
||||||
|
- There are many edge cases to consider
|
||||||
|
- You'd benefit from exploring different approaches
|
||||||
|
|
||||||
|
Example perspectives by task type:
|
||||||
|
- New feature: simplicity vs performance vs maintainability
|
||||||
|
- Bug fix: root cause vs workaround vs prevention
|
||||||
|
- Refactoring: minimal change vs clean architecture
|
||||||
|
|
||||||
|
In the agent prompt:
|
||||||
|
- Provide comprehensive background context from Phase 1 exploration including filenames and code path traces
|
||||||
|
- Describe requirements and constraints
|
||||||
|
- Request a detailed implementation plan
|
||||||
|
|
||||||
|
### Phase 3: Review
|
||||||
|
Goal: Review the plan(s) from Phase 2 and ensure alignment with the user's intentions.
|
||||||
|
1. Read the critical files identified by agents to deepen your understanding
|
||||||
|
2. Ensure that the plans align with the user's original request
|
||||||
|
3. Use question tool to clarify any remaining questions with the user
|
||||||
|
|
||||||
|
### Phase 4: Final Plan
|
||||||
|
Goal: Write your final plan to the plan file (the only file you can edit).
|
||||||
|
- Include only your recommended approach, not all alternatives
|
||||||
|
- Ensure that the plan file is concise enough to scan quickly, but detailed enough to execute effectively
|
||||||
|
- Include the paths of critical files to be modified
|
||||||
|
- Include a verification section describing how to test the changes end-to-end (run the code, use MCP tools, run tests)
|
||||||
|
|
||||||
|
### Phase 5: Call plan_exit tool
|
||||||
|
At the very end of your turn, once you have asked the user questions and are happy with your final plan file - you should always call plan_exit to indicate to the user that you are done planning.
|
||||||
|
This is critical - your turn should only end with either asking the user a question or calling plan_exit. Do not stop unless it's for these 2 reasons.
|
||||||
|
|
||||||
|
**Important:** Use question tool to clarify requirements/approach, use plan_exit to request plan approval. Do NOT use question tool to ask "Is this plan okay?" - that's what plan_exit does.
|
||||||
|
|
||||||
|
NOTE: At any point in time through this workflow you should feel free to ask the user questions or clarifications. Don't make large assumptions about user intent. The goal is to present a well researched plan to the user, and tie any loose ends before implementation begins.
|
||||||
|
</system-reminder>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Option, Schema } from "effect"
|
||||||
|
import { MessageV2 } from "../message-v2"
|
||||||
|
import { Reference } from "@/reference/reference"
|
||||||
|
|
||||||
|
const Source = Schema.Struct({
|
||||||
|
value: Schema.String,
|
||||||
|
start: Schema.Number,
|
||||||
|
end: Schema.Number,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ReferencePromptMetadata = Schema.Struct({
|
||||||
|
name: Schema.String,
|
||||||
|
kind: Schema.Literals(["local", "git", "invalid"]),
|
||||||
|
path: Schema.optional(Schema.String),
|
||||||
|
repository: Schema.optional(Schema.String),
|
||||||
|
branch: Schema.optional(Schema.String),
|
||||||
|
target: Schema.optional(Schema.String),
|
||||||
|
targetPath: Schema.optional(Schema.String),
|
||||||
|
problem: Schema.optional(Schema.String),
|
||||||
|
source: Source,
|
||||||
|
})
|
||||||
|
export type ReferencePromptMetadata = typeof ReferencePromptMetadata.Type
|
||||||
|
|
||||||
|
const decodeReferencePromptMetadata = Schema.decodeUnknownOption(ReferencePromptMetadata)
|
||||||
|
|
||||||
|
export function referencePromptMetadata(input: unknown) {
|
||||||
|
return Option.getOrUndefined(decodeReferencePromptMetadata(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function referenceTextPart(input: {
|
||||||
|
reference: Reference.Resolved
|
||||||
|
source: ReferencePromptMetadata["source"]
|
||||||
|
target?: string
|
||||||
|
targetPath?: string
|
||||||
|
problem?: string
|
||||||
|
}): MessageV2.TextPartInput {
|
||||||
|
const metadata: ReferencePromptMetadata = {
|
||||||
|
name: input.reference.name,
|
||||||
|
kind: input.reference.kind,
|
||||||
|
...(input.reference.kind === "invalid"
|
||||||
|
? { repository: input.reference.repository }
|
||||||
|
: { path: input.reference.path }),
|
||||||
|
...(input.reference.kind === "git"
|
||||||
|
? { repository: input.reference.repository, branch: input.reference.branch }
|
||||||
|
: {}),
|
||||||
|
...(input.target === undefined ? {} : { target: input.target }),
|
||||||
|
...(input.targetPath ? { targetPath: input.targetPath } : {}),
|
||||||
|
problem: input.problem ?? (input.reference.kind === "invalid" ? input.reference.message : undefined),
|
||||||
|
source: input.source,
|
||||||
|
}
|
||||||
|
const label = metadata.target === undefined ? `@${metadata.name}` : `@${metadata.name}/${metadata.target}`
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
synthetic: true,
|
||||||
|
text: [
|
||||||
|
`Referenced configured reference ${label}.`,
|
||||||
|
...(metadata.kind === "local" ? ["Kind: local directory"] : []),
|
||||||
|
...(metadata.kind === "git" ? ["Kind: git repository"] : []),
|
||||||
|
...(metadata.repository ? [`Repository: ${metadata.repository}`] : []),
|
||||||
|
...(metadata.branch ? [`Branch/ref: ${metadata.branch}`] : []),
|
||||||
|
...(metadata.path ? [`Reference root: ${metadata.path}`] : []),
|
||||||
|
...(metadata.targetPath ? [`Resolved path: ${metadata.targetPath}`] : []),
|
||||||
|
...(metadata.problem
|
||||||
|
? [`Problem: ${metadata.problem}`]
|
||||||
|
: [
|
||||||
|
"For targeted context, inspect the reference path directly with Read, Glob, and Grep. For broader research, call the task tool with subagent scout and include this reference path.",
|
||||||
|
]),
|
||||||
|
].join("\n"),
|
||||||
|
metadata: { reference: metadata },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as ReferencePrompt from "./reference"
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import path from "path"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { Agent } from "@/agent/agent"
|
||||||
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
|
import { PartID } from "./schema"
|
||||||
|
import { MessageV2 } from "./message-v2"
|
||||||
|
import * as Session from "./session"
|
||||||
|
import PROMPT_PLAN from "./prompt/plan.txt"
|
||||||
|
import BUILD_SWITCH from "./prompt/build-switch.txt"
|
||||||
|
import PLAN_MODE from "./prompt/plan-mode.txt"
|
||||||
|
|
||||||
|
export const apply = Effect.fn("SessionReminders.apply")(function* (input: {
|
||||||
|
messages: MessageV2.WithParts[]
|
||||||
|
agent: Agent.Info
|
||||||
|
session: Session.Info
|
||||||
|
}) {
|
||||||
|
const flags = yield* RuntimeFlags.Service
|
||||||
|
const fsys = yield* AppFileSystem.Service
|
||||||
|
const sessions = yield* Session.Service
|
||||||
|
const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
|
||||||
|
if (!userMessage) return input.messages
|
||||||
|
|
||||||
|
if (!flags.experimentalPlanMode) {
|
||||||
|
if (input.agent.name === "plan") {
|
||||||
|
userMessage.parts.push({
|
||||||
|
id: PartID.ascending(),
|
||||||
|
messageID: userMessage.info.id,
|
||||||
|
sessionID: userMessage.info.sessionID,
|
||||||
|
type: "text",
|
||||||
|
text: PROMPT_PLAN,
|
||||||
|
synthetic: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
|
||||||
|
if (wasPlan && input.agent.name === "build") {
|
||||||
|
userMessage.parts.push({
|
||||||
|
id: PartID.ascending(),
|
||||||
|
messageID: userMessage.info.id,
|
||||||
|
sessionID: userMessage.info.sessionID,
|
||||||
|
type: "text",
|
||||||
|
text: BUILD_SWITCH,
|
||||||
|
synthetic: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return input.messages
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant")
|
||||||
|
if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") {
|
||||||
|
const ctx = yield* InstanceState.context
|
||||||
|
const plan = Session.plan(input.session, ctx)
|
||||||
|
const exists = yield* fsys.existsSafe(plan)
|
||||||
|
const part = yield* sessions.updatePart({
|
||||||
|
id: PartID.ascending(),
|
||||||
|
messageID: userMessage.info.id,
|
||||||
|
sessionID: userMessage.info.sessionID,
|
||||||
|
type: "text",
|
||||||
|
text: exists
|
||||||
|
? `${BUILD_SWITCH}\n\nA plan file exists at ${plan}. You should execute on the plan defined within it`
|
||||||
|
: BUILD_SWITCH,
|
||||||
|
synthetic: true,
|
||||||
|
})
|
||||||
|
userMessage.parts.push(part)
|
||||||
|
return input.messages
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.agent.name !== "plan" || assistantMessage?.info.agent === "plan") return input.messages
|
||||||
|
|
||||||
|
const ctx = yield* InstanceState.context
|
||||||
|
const plan = Session.plan(input.session, ctx)
|
||||||
|
const exists = yield* fsys.existsSafe(plan)
|
||||||
|
if (!exists) yield* fsys.ensureDir(path.dirname(plan)).pipe(Effect.catch(Effect.die))
|
||||||
|
const part = yield* sessions.updatePart({
|
||||||
|
id: PartID.ascending(),
|
||||||
|
messageID: userMessage.info.id,
|
||||||
|
sessionID: userMessage.info.sessionID,
|
||||||
|
type: "text",
|
||||||
|
text: PLAN_MODE.replace("${planInfo}", () =>
|
||||||
|
exists
|
||||||
|
? `A plan file already exists at ${plan}. You can read it and make incremental edits using the edit tool.`
|
||||||
|
: `No plan file exists yet. You should create your plan at ${plan} using the write tool.`,
|
||||||
|
),
|
||||||
|
synthetic: true,
|
||||||
|
})
|
||||||
|
userMessage.parts.push(part)
|
||||||
|
return input.messages
|
||||||
|
})
|
||||||
|
|
||||||
|
export * as SessionReminders from "./reminders"
|
||||||
@@ -4,7 +4,8 @@ import { BackgroundJob } from "@/background/job"
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { Decimal } from "decimal.js"
|
import { Decimal } from "decimal.js"
|
||||||
import { type ProviderMetadata, type LanguageModelUsage } from "ai"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
|
import type { ProviderMetadata, Usage } from "@opencode-ai/llm"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
|
|
||||||
import { Database } from "@/storage/db"
|
import { Database } from "@/storage/db"
|
||||||
@@ -374,21 +375,19 @@ export function plan(input: { slug: string; time: { created: number } }, instanc
|
|||||||
return path.join(base, [input.time.created, input.slug].join("-") + ".md")
|
return path.join(base, [input.time.created, input.slug].join("-") + ".md")
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsage; metadata?: ProviderMetadata }) => {
|
export const getUsage = (input: { model: Provider.Model; usage: Usage; metadata?: ProviderMetadata }) => {
|
||||||
const safe = (value: number) => {
|
const safe = (value: number) => {
|
||||||
if (!Number.isFinite(value)) return 0
|
if (!Number.isFinite(value)) return 0
|
||||||
return Math.max(0, value)
|
return Math.max(0, value)
|
||||||
}
|
}
|
||||||
const inputTokens = safe(input.usage.inputTokens ?? 0)
|
const inputTokens = safe(input.usage.inputTokens ?? 0)
|
||||||
const outputTokens = safe(input.usage.outputTokens ?? 0)
|
const outputTokens = safe(input.usage.outputTokens ?? 0)
|
||||||
const reasoningTokens = safe(input.usage.outputTokenDetails?.reasoningTokens ?? input.usage.reasoningTokens ?? 0)
|
const reasoningTokens = safe(input.usage.reasoningTokens ?? 0)
|
||||||
|
|
||||||
const cacheReadInputTokens = safe(
|
const cacheReadInputTokens = safe(input.usage.cacheReadInputTokens ?? 0)
|
||||||
input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0,
|
|
||||||
)
|
|
||||||
const cacheWriteInputTokens = safe(
|
const cacheWriteInputTokens = safe(
|
||||||
Number(
|
Number(
|
||||||
input.usage.inputTokenDetails?.cacheWriteTokens ??
|
input.usage.cacheWriteInputTokens ??
|
||||||
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
|
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
|
||||||
// google-vertex-anthropic returns metadata under "vertex" key
|
// google-vertex-anthropic returns metadata under "vertex" key
|
||||||
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
|
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { Agent } from "@/agent/agent"
|
||||||
|
import { Provider } from "@/provider/provider"
|
||||||
|
import { ProviderTransform } from "@/provider/transform"
|
||||||
|
import { MCP } from "@/mcp"
|
||||||
|
import { Permission } from "@/permission"
|
||||||
|
import { Tool } from "@/tool/tool"
|
||||||
|
import { ToolJsonSchema } from "@/tool/json-schema"
|
||||||
|
import { ToolRegistry } from "@/tool/registry"
|
||||||
|
import { Truncate } from "@/tool/truncate"
|
||||||
|
import { ModelID } from "@/provider/schema"
|
||||||
|
import { Plugin } from "@/plugin"
|
||||||
|
import type { TaskPromptOps } from "@/tool/task"
|
||||||
|
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { MessageV2 } from "./message-v2"
|
||||||
|
import * as Session from "./session"
|
||||||
|
import { SessionProcessor } from "./processor"
|
||||||
|
import { PartID } from "./schema"
|
||||||
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
|
import { EffectBridge } from "@/effect/bridge"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "session.tools" })
|
||||||
|
|
||||||
|
export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||||
|
agent: Agent.Info
|
||||||
|
model: Provider.Model
|
||||||
|
session: Session.Info
|
||||||
|
processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">
|
||||||
|
bypassAgentCheck: boolean
|
||||||
|
messages: MessageV2.WithParts[]
|
||||||
|
promptOps: TaskPromptOps
|
||||||
|
}) {
|
||||||
|
using _ = log.time("resolveTools")
|
||||||
|
const tools: Record<string, AITool> = {}
|
||||||
|
const run = yield* EffectBridge.make()
|
||||||
|
const plugin = yield* Plugin.Service
|
||||||
|
const permission = yield* Permission.Service
|
||||||
|
const registry = yield* ToolRegistry.Service
|
||||||
|
const mcp = yield* MCP.Service
|
||||||
|
const truncate = yield* Truncate.Service
|
||||||
|
|
||||||
|
const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({
|
||||||
|
sessionID: input.session.id,
|
||||||
|
abort: options.abortSignal!,
|
||||||
|
messageID: input.processor.message.id,
|
||||||
|
callID: options.toolCallId,
|
||||||
|
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps },
|
||||||
|
agent: input.agent.name,
|
||||||
|
messages: input.messages,
|
||||||
|
metadata: (val) =>
|
||||||
|
input.processor.updateToolCall(options.toolCallId, (match) => {
|
||||||
|
if (!["running", "pending"].includes(match.state.status)) return match
|
||||||
|
return {
|
||||||
|
...match,
|
||||||
|
state: {
|
||||||
|
title: val.title,
|
||||||
|
metadata: val.metadata,
|
||||||
|
status: "running",
|
||||||
|
input: args,
|
||||||
|
time: { start: Date.now() },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
ask: (req) =>
|
||||||
|
permission
|
||||||
|
.ask({
|
||||||
|
...req,
|
||||||
|
sessionID: input.session.id,
|
||||||
|
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
|
||||||
|
ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
|
||||||
|
})
|
||||||
|
.pipe(Effect.orDie),
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const item of yield* registry.tools({
|
||||||
|
modelID: ModelID.make(input.model.api.id),
|
||||||
|
providerID: input.model.providerID,
|
||||||
|
agent: input.agent,
|
||||||
|
})) {
|
||||||
|
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
|
||||||
|
tools[item.id] = tool({
|
||||||
|
description: item.description,
|
||||||
|
inputSchema: jsonSchema(schema),
|
||||||
|
execute(args, options) {
|
||||||
|
return run.promise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const ctx = context(args, options)
|
||||||
|
yield* plugin.trigger(
|
||||||
|
"tool.execute.before",
|
||||||
|
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
|
||||||
|
{ args },
|
||||||
|
)
|
||||||
|
const result = yield* item.execute(args, ctx)
|
||||||
|
const output = {
|
||||||
|
...result,
|
||||||
|
attachments: result.attachments?.map((attachment) => ({
|
||||||
|
...attachment,
|
||||||
|
id: PartID.ascending(),
|
||||||
|
sessionID: ctx.sessionID,
|
||||||
|
messageID: input.processor.message.id,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
yield* plugin.trigger(
|
||||||
|
"tool.execute.after",
|
||||||
|
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
if (options.abortSignal?.aborted) {
|
||||||
|
yield* input.processor.completeToolCall(options.toolCallId, output)
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, item] of Object.entries(yield* mcp.tools())) {
|
||||||
|
const execute = item.execute
|
||||||
|
if (!execute) continue
|
||||||
|
|
||||||
|
const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))
|
||||||
|
const transformed = ProviderTransform.schema(input.model, schema)
|
||||||
|
item.inputSchema = jsonSchema(transformed)
|
||||||
|
item.execute = (args, opts) =>
|
||||||
|
run.promise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const ctx = context(args, opts)
|
||||||
|
yield* plugin.trigger(
|
||||||
|
"tool.execute.before",
|
||||||
|
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
||||||
|
{ args },
|
||||||
|
)
|
||||||
|
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () {
|
||||||
|
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
|
||||||
|
return yield* Effect.promise(() => execute(args, opts))
|
||||||
|
}).pipe(
|
||||||
|
Effect.withSpan("Tool.execute", {
|
||||||
|
attributes: {
|
||||||
|
"tool.name": key,
|
||||||
|
"tool.call_id": opts.toolCallId,
|
||||||
|
"session.id": ctx.sessionID,
|
||||||
|
"message.id": input.processor.message.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
yield* plugin.trigger(
|
||||||
|
"tool.execute.after",
|
||||||
|
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
const textParts: string[] = []
|
||||||
|
const attachments: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[] = []
|
||||||
|
for (const contentItem of result.content) {
|
||||||
|
if (contentItem.type === "text") textParts.push(contentItem.text)
|
||||||
|
else if (contentItem.type === "image") {
|
||||||
|
attachments.push({
|
||||||
|
type: "file",
|
||||||
|
mime: contentItem.mimeType,
|
||||||
|
url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
|
||||||
|
})
|
||||||
|
} else if (contentItem.type === "resource") {
|
||||||
|
const { resource } = contentItem
|
||||||
|
if (resource.text) textParts.push(resource.text)
|
||||||
|
if (resource.blob) {
|
||||||
|
attachments.push({
|
||||||
|
type: "file",
|
||||||
|
mime: resource.mimeType ?? "application/octet-stream",
|
||||||
|
url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
|
||||||
|
filename: resource.uri,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent)
|
||||||
|
const metadata = {
|
||||||
|
...result.metadata,
|
||||||
|
truncated: truncated.truncated,
|
||||||
|
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
title: "",
|
||||||
|
metadata,
|
||||||
|
output: truncated.content,
|
||||||
|
attachments: attachments.map((attachment) => ({
|
||||||
|
...attachment,
|
||||||
|
id: PartID.ascending(),
|
||||||
|
sessionID: ctx.sessionID,
|
||||||
|
messageID: input.processor.message.id,
|
||||||
|
})),
|
||||||
|
content: result.content,
|
||||||
|
}
|
||||||
|
if (opts.abortSignal?.aborted) {
|
||||||
|
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
tools[key] = item
|
||||||
|
}
|
||||||
|
|
||||||
|
return tools
|
||||||
|
})
|
||||||
|
|
||||||
|
export * as SessionTools from "./tools"
|
||||||
@@ -168,6 +168,8 @@ export const layer = Layer.effect(
|
|||||||
fn: (evt: { properties: any }) => Effect.Effect<void, unknown>,
|
fn: (evt: { properties: any }) => Effect.Effect<void, unknown>,
|
||||||
) =>
|
) =>
|
||||||
bus.subscribe(def as never).pipe(
|
bus.subscribe(def as never).pipe(
|
||||||
|
Effect.flatMap((stream) =>
|
||||||
|
stream.pipe(
|
||||||
Stream.runForEach((evt) =>
|
Stream.runForEach((evt) =>
|
||||||
fn(evt).pipe(
|
fn(evt).pipe(
|
||||||
Effect.catchCause((cause) =>
|
Effect.catchCause((cause) =>
|
||||||
@@ -178,6 +180,8 @@ export const layer = Layer.effect(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Effect.forkScoped,
|
Effect.forkScoped,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
yield* watch(Session.Event.Updated, (evt) =>
|
yield* watch(Session.Event.Updated, (evt) =>
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import { eq } from "drizzle-orm"
|
|||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { Bus as ProjectBus } from "@/bus"
|
import { Bus as ProjectBus } from "@/bus"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import type { InstanceContext } from "@/project/instance-context"
|
|
||||||
import { EventSequenceTable, EventTable } from "./event.sql"
|
import { EventSequenceTable, EventTable } from "./event.sql"
|
||||||
import type { WorkspaceID } from "@/control-plane/schema"
|
|
||||||
import { EventID } from "./schema"
|
import { EventID } from "./schema"
|
||||||
import { Context, Effect, Layer, Schema as EffectSchema } from "effect"
|
import { Context, Effect, Layer, Schema as EffectSchema } from "effect"
|
||||||
import type { DeepMutable } from "@opencode-ai/core/schema"
|
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||||
@@ -17,7 +15,7 @@ import { EventV2 } from "@opencode-ai/core/event"
|
|||||||
import { serviceUse } from "@/effect/service-use"
|
import { serviceUse } from "@/effect/service-use"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { attachWith } from "@/effect/run-service"
|
import { EffectBridge } from "@/effect/bridge"
|
||||||
|
|
||||||
// Keep `Event["data"]` mutable because projectors mutate the persisted shape
|
// Keep `Event["data"]` mutable because projectors mutate the persisted shape
|
||||||
// when writing to the database. Bus payloads (`Properties`) stay readonly —
|
// when writing to the database. Bus payloads (`Properties`) stay readonly —
|
||||||
@@ -51,10 +49,6 @@ export type SerializedEvent<Def extends Definition = Definition> = Event<Def> &
|
|||||||
|
|
||||||
type ProjectorFunc = (db: Database.TxOrDb, data: unknown, event: Event) => void
|
type ProjectorFunc = (db: Database.TxOrDb, data: unknown, event: Event) => void
|
||||||
type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise<unknown>
|
type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise<unknown>
|
||||||
type PublishContext = {
|
|
||||||
instance?: InstanceContext
|
|
||||||
workspace?: WorkspaceID
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly run: <Def extends Definition>(
|
readonly run: <Def extends Definition>(
|
||||||
@@ -107,16 +101,14 @@ export const layer = Layer.effect(Service)(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const publish = !!options?.publish
|
const publish = !!options?.publish
|
||||||
const context = publish
|
// Bridge captures handler-fiber refs (InstanceRef/WorkspaceRef) and the
|
||||||
? {
|
// full Effect context, so the forked publish + GlobalBus emit run with
|
||||||
instance: yield* InstanceState.context,
|
// the right state without a per-call attachWith.
|
||||||
workspace: yield* InstanceState.workspaceID,
|
const bridge = yield* EffectBridge.make()
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
process(def, event, {
|
process(def, event, {
|
||||||
bus,
|
bus,
|
||||||
|
bridge,
|
||||||
publish,
|
publish,
|
||||||
context,
|
|
||||||
ownerID: options?.ownerID,
|
ownerID: options?.ownerID,
|
||||||
experimentalWorkspaces: flags.experimentalWorkspaces,
|
experimentalWorkspaces: flags.experimentalWorkspaces,
|
||||||
})
|
})
|
||||||
@@ -154,12 +146,7 @@ export const layer = Layer.effect(Service)(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { publish = true } = options || {}
|
const { publish = true } = options || {}
|
||||||
const context = publish
|
const bridge = yield* EffectBridge.make()
|
||||||
? {
|
|
||||||
instance: yield* InstanceState.context,
|
|
||||||
workspace: yield* InstanceState.workspaceID,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
// Note that this is an "immediate" transaction which is critical.
|
// Note that this is an "immediate" transaction which is critical.
|
||||||
// We need to make sure we can safely read and write with nothing
|
// We need to make sure we can safely read and write with nothing
|
||||||
@@ -175,7 +162,7 @@ export const layer = Layer.effect(Service)(
|
|||||||
const seq = row?.seq != null ? row.seq + 1 : 0
|
const seq = row?.seq != null ? row.seq + 1 : 0
|
||||||
|
|
||||||
const event = { id, seq, aggregateID: agg, data }
|
const event = { id, seq, aggregateID: agg, data }
|
||||||
process(def, event, { bus, publish, context, experimentalWorkspaces: flags.experimentalWorkspaces })
|
process(def, event, { bus, bridge, publish, experimentalWorkspaces: flags.experimentalWorkspaces })
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
behavior: "immediate",
|
behavior: "immediate",
|
||||||
@@ -308,8 +295,8 @@ function process<Def extends Definition>(
|
|||||||
event: Event<Def>,
|
event: Event<Def>,
|
||||||
options: {
|
options: {
|
||||||
bus: ProjectBus.Interface
|
bus: ProjectBus.Interface
|
||||||
|
bridge: EffectBridge.Shape
|
||||||
publish: boolean
|
publish: boolean
|
||||||
context?: PublishContext
|
|
||||||
ownerID?: string
|
ownerID?: string
|
||||||
experimentalWorkspaces: boolean
|
experimentalWorkspaces: boolean
|
||||||
},
|
},
|
||||||
@@ -351,29 +338,22 @@ function process<Def extends Definition>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Database.effect(() => {
|
Database.effect(() => {
|
||||||
if (options?.publish) {
|
if (!options.publish) return
|
||||||
if (!options.context?.instance) {
|
|
||||||
throw new Error("SyncEvent.process: publish requires instance context")
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = convertEvent(def.type, event.data)
|
const result = convertEvent(def.type, event.data)
|
||||||
|
// The bridge was built inside the caller's fiber so it already carries
|
||||||
|
// InstanceRef/WorkspaceRef and the full Effect context. Both the bus
|
||||||
|
// publish and the GlobalBus emit run inside the forked Effect so they
|
||||||
|
// share the same instance/workspace lookup.
|
||||||
const publish = (data: unknown) =>
|
const publish = (data: unknown) =>
|
||||||
Effect.runPromise(
|
options.bridge.fork(
|
||||||
attachWith(options.bus.publish(def, data as Properties<Def>, { id: event.id }), {
|
Effect.gen(function* () {
|
||||||
instance: options.context?.instance,
|
yield* options.bus.publish(def, data as Properties<Def>, { id: event.id })
|
||||||
workspace: options.context?.workspace,
|
const instance = yield* InstanceState.context
|
||||||
}),
|
const workspace = yield* InstanceState.workspaceID
|
||||||
)
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
void result.then(publish)
|
|
||||||
} else {
|
|
||||||
void publish(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
directory: options.context.instance.directory,
|
directory: instance.directory,
|
||||||
project: options.context.instance.project.id,
|
project: instance.project.id,
|
||||||
workspace: options.context.workspace,
|
workspace,
|
||||||
payload: {
|
payload: {
|
||||||
type: "sync",
|
type: "sync",
|
||||||
syncEvent: {
|
syncEvent: {
|
||||||
@@ -382,6 +362,12 @@ function process<Def extends Definition>(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
void result.then(publish)
|
||||||
|
} else {
|
||||||
|
publish(result)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { ProviderID, type ModelID } from "../provider/schema"
|
|||||||
import { WebSearchTool } from "./websearch"
|
import { WebSearchTool } from "./websearch"
|
||||||
import { RepoCloneTool } from "./repo_clone"
|
import { RepoCloneTool } from "./repo_clone"
|
||||||
import { RepoOverviewTool } from "./repo_overview"
|
import { RepoOverviewTool } from "./repo_overview"
|
||||||
|
import { RepositoryCache } from "@/reference/repository-cache"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { LspTool } from "./lsp"
|
import { LspTool } from "./lsp"
|
||||||
import * as Truncate from "./truncate"
|
import * as Truncate from "./truncate"
|
||||||
@@ -39,6 +40,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|||||||
import { Ripgrep } from "../file/ripgrep"
|
import { Ripgrep } from "../file/ripgrep"
|
||||||
import { Format } from "../format"
|
import { Format } from "../format"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { EffectBridge } from "@/effect/bridge"
|
||||||
import { Question } from "../question"
|
import { Question } from "../question"
|
||||||
import { Todo } from "../session/todo"
|
import { Todo } from "../session/todo"
|
||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
@@ -93,6 +95,7 @@ export const layer: Layer.Layer<
|
|||||||
| BackgroundJob.Service
|
| BackgroundJob.Service
|
||||||
| Provider.Service
|
| Provider.Service
|
||||||
| Git.Service
|
| Git.Service
|
||||||
|
| RepositoryCache.Service
|
||||||
| Reference.Service
|
| Reference.Service
|
||||||
| LSP.Service
|
| LSP.Service
|
||||||
| Instruction.Service
|
| Instruction.Service
|
||||||
@@ -156,9 +159,12 @@ export const layer: Layer.Layer<
|
|||||||
description: def.description,
|
description: def.description,
|
||||||
execute: (args, toolCtx) =>
|
execute: (args, toolCtx) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
// Bridge the host's Effect-based `ask` into a Promise-returning
|
||||||
|
// function for the plugin to make sure context persists
|
||||||
|
const bridge = yield* EffectBridge.make()
|
||||||
const pluginCtx: PluginToolContext = {
|
const pluginCtx: PluginToolContext = {
|
||||||
...toolCtx,
|
...toolCtx,
|
||||||
ask: (req) => toolCtx.ask(req),
|
ask: (req) => bridge.promise(toolCtx.ask(req)),
|
||||||
directory: ctx.directory,
|
directory: ctx.directory,
|
||||||
worktree: ctx.worktree,
|
worktree: ctx.worktree,
|
||||||
}
|
}
|
||||||
@@ -378,7 +384,7 @@ export const defaultLayer = Layer.suspend(() =>
|
|||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
Layer.provide(Layer.mergeAll(SessionStatus.defaultLayer, BackgroundJob.defaultLayer)),
|
Layer.provide(Layer.mergeAll(SessionStatus.defaultLayer, BackgroundJob.defaultLayer)),
|
||||||
Layer.provide(Provider.defaultLayer),
|
Layer.provide(Provider.defaultLayer),
|
||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Layer.mergeAll(Git.defaultLayer, RepositoryCache.defaultLayer)),
|
||||||
Layer.provide(Reference.defaultLayer),
|
Layer.provide(Reference.defaultLayer),
|
||||||
Layer.provide(LSP.defaultLayer),
|
Layer.provide(LSP.defaultLayer),
|
||||||
Layer.provide(Instruction.defaultLayer),
|
Layer.provide(Instruction.defaultLayer),
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
|
||||||
import { Git } from "@/git"
|
|
||||||
import DESCRIPTION from "./repo_clone.txt"
|
import DESCRIPTION from "./repo_clone.txt"
|
||||||
import * as Tool from "./tool"
|
import * as Tool from "./tool"
|
||||||
import { parseRemoteRepositoryReference, repositoryCachePath, validateRepositoryBranch } from "@/util/repository"
|
import { repositoryCachePath } from "@/util/repository"
|
||||||
import { RepositoryCache } from "@/reference/repository-cache"
|
import { RepositoryCache } from "@/reference/repository-cache"
|
||||||
|
|
||||||
export const Parameters = Schema.Struct({
|
export const Parameters = Schema.Struct({
|
||||||
@@ -28,19 +26,18 @@ type Metadata = {
|
|||||||
branch?: string
|
branch?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, AppFileSystem.Service | Git.Service>(
|
export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, RepositoryCache.Service>(
|
||||||
"repo_clone",
|
"repo_clone",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const cache = yield* RepositoryCache.Service
|
||||||
const git = yield* Git.Service
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
parameters: Parameters,
|
parameters: Parameters,
|
||||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
|
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const reference = parseRemoteRepositoryReference(params.repository)
|
const reference = yield* RepositoryCache.parseRemoteReference(params.repository)
|
||||||
if (params.branch) validateRepositoryBranch(params.branch)
|
if (params.branch) yield* RepositoryCache.validateBranch(params.branch)
|
||||||
|
|
||||||
const repository = reference.label
|
const repository = reference.label
|
||||||
const remote = reference.remote
|
const remote = reference.remote
|
||||||
@@ -59,10 +56,7 @@ export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, AppFileSys
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = yield* RepositoryCache.ensure(
|
const result = yield* cache.ensure({ reference, refresh: params.refresh, branch: params.branch })
|
||||||
{ reference, refresh: params.refresh, branch: params.branch },
|
|
||||||
{ fs, git },
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
title: repository,
|
title: repository,
|
||||||
metadata: result,
|
metadata: result,
|
||||||
@@ -74,7 +68,10 @@ export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, AppFileSys
|
|||||||
...(result.head ? [`HEAD: ${result.head}`] : []),
|
...(result.head ? [`HEAD: ${result.head}`] : []),
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
}
|
}
|
||||||
}).pipe(Effect.orDie),
|
}).pipe(
|
||||||
|
Effect.catchIf(RepositoryCache.isError, (error) => Effect.fail(new Error(error.message))),
|
||||||
|
Effect.orDie,
|
||||||
|
),
|
||||||
} satisfies Tool.DefWithoutID<typeof Parameters, Metadata>
|
} satisfies Tool.DefWithoutID<typeof Parameters, Metadata>
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from "path"
|
|||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
|
||||||
export type Reference = {
|
type BaseReference = {
|
||||||
host: string
|
host: string
|
||||||
path: string
|
path: string
|
||||||
segments: string[]
|
segments: string[]
|
||||||
@@ -10,10 +10,20 @@ export type Reference = {
|
|||||||
repo: string
|
repo: string
|
||||||
remote: string
|
remote: string
|
||||||
label: string
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RemoteReference = BaseReference & {
|
||||||
protocol?: string
|
protocol?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalize(input: string) {
|
export type FileReference = BaseReference & {
|
||||||
|
host: "file"
|
||||||
|
protocol: "file:"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Reference = RemoteReference | FileReference
|
||||||
|
|
||||||
|
function normalizeRepositoryInput(input: string) {
|
||||||
return input
|
return input
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/^git\+/, "")
|
.replace(/^git\+/, "")
|
||||||
@@ -54,7 +64,7 @@ function githubRemote(pathname: string) {
|
|||||||
return new URL(`${pathname}.git`, withSlash(base)).href
|
return new URL(`${pathname}.git`, withSlash(base)).href
|
||||||
}
|
}
|
||||||
|
|
||||||
function build(input: { host: string; segments: string[]; remote?: string; protocol?: string }) {
|
function buildRemoteReference(input: { host: string; segments: string[]; remote?: string; protocol?: string }) {
|
||||||
const segments = input.segments.map(trimGitSuffix).filter(Boolean)
|
const segments = input.segments.map(trimGitSuffix).filter(Boolean)
|
||||||
if (!safeHost(input.host) || !segments.length || segments.some((segment) => !safeSegment(segment))) return null
|
if (!safeHost(input.host) || !segments.length || segments.some((segment) => !safeSegment(segment))) return null
|
||||||
const pathname = segments.join("/")
|
const pathname = segments.join("/")
|
||||||
@@ -69,10 +79,10 @@ function build(input: { host: string; segments: string[]; remote?: string; proto
|
|||||||
remote: input.remote ?? (host === "github.com" ? githubRemote(pathname) : `https://${host}/${pathname}.git`),
|
remote: input.remote ?? (host === "github.com" ? githubRemote(pathname) : `https://${host}/${pathname}.git`),
|
||||||
label: host === "github.com" && segments.length === 2 ? pathname : `${host}/${pathname}`,
|
label: host === "github.com" && segments.length === 2 ? pathname : `${host}/${pathname}`,
|
||||||
protocol: input.protocol,
|
protocol: input.protocol,
|
||||||
} satisfies Reference
|
} satisfies RemoteReference
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFile(input: { url: URL; remote: string }) {
|
function buildFileReference(input: { url: URL; remote: string }) {
|
||||||
const filePath = path.normalize(fileURLToPath(input.url))
|
const filePath = path.normalize(fileURLToPath(input.url))
|
||||||
const segments = filePath.split(/[\\/]+/).filter(Boolean)
|
const segments = filePath.split(/[\\/]+/).filter(Boolean)
|
||||||
if (!segments.length) return null
|
if (!segments.length) return null
|
||||||
@@ -85,36 +95,38 @@ function buildFile(input: { url: URL; remote: string }) {
|
|||||||
remote: input.remote,
|
remote: input.remote,
|
||||||
label: filePath,
|
label: filePath,
|
||||||
protocol: "file:",
|
protocol: "file:",
|
||||||
} satisfies Reference
|
} satisfies FileReference
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseRepositoryReference(input: string) {
|
export function parseRepositoryReference(input: string) {
|
||||||
const cleaned = normalize(input)
|
const cleaned = normalizeRepositoryInput(input)
|
||||||
if (!cleaned) return null
|
if (!cleaned) return null
|
||||||
|
|
||||||
const githubPrefixed = cleaned.match(/^github:([^/\s]+)\/([^/\s]+)$/)
|
const githubPrefixed = cleaned.match(/^github:([^/\s]+)\/([^/\s]+)$/)
|
||||||
if (githubPrefixed) return build({ host: "github.com", segments: [githubPrefixed[1], githubPrefixed[2]] })
|
if (githubPrefixed) {
|
||||||
|
return buildRemoteReference({ host: "github.com", segments: [githubPrefixed[1], githubPrefixed[2]] })
|
||||||
|
}
|
||||||
|
|
||||||
if (!cleaned.includes("://")) {
|
if (!cleaned.includes("://")) {
|
||||||
const scp = cleaned.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/)
|
const scp = cleaned.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/)
|
||||||
if (scp) return build({ host: scp[1], segments: parts(scp[2]), remote: cleaned })
|
if (scp) return buildRemoteReference({ host: scp[1], segments: parts(scp[2]), remote: cleaned })
|
||||||
|
|
||||||
const direct = parts(cleaned)
|
const direct = parts(cleaned)
|
||||||
if (direct.length >= 2 && hostLike(direct[0])) {
|
if (direct.length >= 2 && hostLike(direct[0])) {
|
||||||
return build({ host: direct[0], segments: direct.slice(1) })
|
return buildRemoteReference({ host: direct[0], segments: direct.slice(1) })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (direct.length === 2) {
|
if (direct.length === 2) {
|
||||||
return build({ host: "github.com", segments: direct })
|
return buildRemoteReference({ host: "github.com", segments: direct })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(cleaned)
|
const url = new URL(cleaned)
|
||||||
if (url.protocol === "file:") return buildFile({ url, remote: cleaned })
|
if (url.protocol === "file:") return buildFileReference({ url, remote: cleaned })
|
||||||
const pathname = parts(url.pathname)
|
const pathname = parts(url.pathname)
|
||||||
const host = url.host
|
const host = url.host
|
||||||
return build({
|
return buildRemoteReference({
|
||||||
host,
|
host,
|
||||||
segments: pathname,
|
segments: pathname,
|
||||||
remote: host === "github.com" ? githubRemote(pathname.join("/")) : cleaned,
|
remote: host === "github.com" ? githubRemote(pathname.join("/")) : cleaned,
|
||||||
@@ -125,10 +137,18 @@ export function parseRepositoryReference(input: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isFileRepositoryReference(reference: Reference): reference is FileReference {
|
||||||
|
return reference.protocol === "file:"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRemoteRepositoryReference(reference: Reference): reference is RemoteReference {
|
||||||
|
return !isFileRepositoryReference(reference)
|
||||||
|
}
|
||||||
|
|
||||||
export function parseRemoteRepositoryReference(input: string) {
|
export function parseRemoteRepositoryReference(input: string) {
|
||||||
const reference = parseRepositoryReference(input)
|
const reference = parseRepositoryReference(input)
|
||||||
if (!reference) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand")
|
if (!reference) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand")
|
||||||
if (reference.protocol === "file:") throw new Error("Local file repositories are not supported")
|
if (!isRemoteRepositoryReference(reference)) throw new Error("Local file repositories are not supported")
|
||||||
return reference
|
return reference
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +161,7 @@ export function validateRepositoryBranch(branch: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseGitHubRemote(input: string) {
|
export function parseGitHubRemote(input: string) {
|
||||||
const cleaned = normalize(input)
|
const cleaned = normalizeRepositoryInput(input)
|
||||||
if (!cleaned.includes("://") && !cleaned.match(/^(?:[^@/\s]+@)?github\.com:/)) return null
|
if (!cleaned.includes("://") && !cleaned.match(/^(?:[^@/\s]+@)?github\.com:/)) return null
|
||||||
|
|
||||||
const parsed = parseRepositoryReference(cleaned)
|
const parsed = parseRepositoryReference(cleaned)
|
||||||
@@ -153,6 +173,10 @@ export function repositoryCachePath(input: Reference) {
|
|||||||
return path.join(Global.Path.repos, ...input.host.split(":"), ...input.segments)
|
return path.join(Global.Path.repos, ...input.host.split(":"), ...input.segments)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function repositoryCacheIdentity(input: Reference) {
|
||||||
|
return `${input.host}/${input.path}`
|
||||||
|
}
|
||||||
|
|
||||||
export function sameRepositoryReference(left: Reference, right: Reference) {
|
export function sameRepositoryReference(left: Reference, right: Reference) {
|
||||||
return left.host === right.host && left.path === right.path
|
return repositoryCacheIdentity(left) === repositoryCacheIdentity(right)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ describe("Bus (Effect-native)", () => {
|
|||||||
const done = yield* Deferred.make<void>()
|
const done = yield* Deferred.make<void>()
|
||||||
const ready = yield* Latch.make()
|
const ready = yield* Latch.make()
|
||||||
|
|
||||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (evt.properties.value < 0) {
|
if (evt.properties.value < 0) {
|
||||||
yield* ready.open
|
yield* ready.open
|
||||||
@@ -71,7 +71,7 @@ describe("Bus (Effect-native)", () => {
|
|||||||
const done = yield* Deferred.make<void>()
|
const done = yield* Deferred.make<void>()
|
||||||
const ready = yield* Latch.make()
|
const ready = yield* Latch.make()
|
||||||
|
|
||||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (evt.properties.value < 0) {
|
if (evt.properties.value < 0) {
|
||||||
yield* ready.open
|
yield* ready.open
|
||||||
@@ -98,7 +98,7 @@ describe("Bus (Effect-native)", () => {
|
|||||||
const done = yield* Deferred.make<void>()
|
const done = yield* Deferred.make<void>()
|
||||||
const ready = yield* Latch.make()
|
const ready = yield* Latch.make()
|
||||||
|
|
||||||
yield* Stream.runForEach(bus.subscribeAll(), (evt) =>
|
yield* Stream.runForEach(yield* bus.subscribeAll(), (evt) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (evt.type === TestEvent.Warmup.type) {
|
if (evt.type === TestEvent.Warmup.type) {
|
||||||
yield* ready.open
|
yield* ready.open
|
||||||
@@ -129,7 +129,7 @@ describe("Bus (Effect-native)", () => {
|
|||||||
const readyA = yield* Latch.make()
|
const readyA = yield* Latch.make()
|
||||||
const readyB = yield* Latch.make()
|
const readyB = yield* Latch.make()
|
||||||
|
|
||||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (evt.properties.value < 0) {
|
if (evt.properties.value < 0) {
|
||||||
yield* readyA.open
|
yield* readyA.open
|
||||||
@@ -140,7 +140,7 @@ describe("Bus (Effect-native)", () => {
|
|||||||
}),
|
}),
|
||||||
).pipe(Effect.forkScoped)
|
).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (evt.properties.value < 0) {
|
if (evt.properties.value < 0) {
|
||||||
yield* readyB.open
|
yield* readyB.open
|
||||||
@@ -162,6 +162,92 @@ describe("Bus (Effect-native)", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// RACE 1: eager subscription means publishing immediately after yield*
|
||||||
|
// bus.subscribe is delivered. Regression for the old lazy `Stream.unwrap`
|
||||||
|
// shape where PubSub.subscribe ran on first pull and missed any publish
|
||||||
|
// in the hand-off window.
|
||||||
|
it.instance("eager subscribe: publish after yield* is delivered without consumer-activation race", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const bus = yield* Bus.Service
|
||||||
|
const stream = yield* bus.subscribe(TestEvent.Ping)
|
||||||
|
|
||||||
|
// Hand-off window: subscription is alive (we yielded). Publish goes
|
||||||
|
// straight into the subscription queue, even with no consumer running.
|
||||||
|
yield* bus.publish(TestEvent.Ping, { value: 99 })
|
||||||
|
|
||||||
|
const collected = yield* stream.pipe(
|
||||||
|
Stream.take(1),
|
||||||
|
Stream.runCollect,
|
||||||
|
Effect.timeout("400 millis"),
|
||||||
|
Effect.option,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(collected._tag).toBe("Some")
|
||||||
|
if (collected._tag === "Some") {
|
||||||
|
const arr = Array.from(collected.value)
|
||||||
|
expect(arr[0].properties.value).toBe(99)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// RACE 2: same property for subscribeAll.
|
||||||
|
it.instance("eager subscribeAll: publish after yield* is delivered", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const bus = yield* Bus.Service
|
||||||
|
const stream = yield* bus.subscribeAll()
|
||||||
|
|
||||||
|
yield* bus.publish(TestEvent.Ping, { value: 42 })
|
||||||
|
|
||||||
|
const collected = yield* stream.pipe(
|
||||||
|
Stream.take(1),
|
||||||
|
Stream.runCollect,
|
||||||
|
Effect.timeout("400 millis"),
|
||||||
|
Effect.option,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(collected._tag).toBe("Some")
|
||||||
|
if (collected._tag === "Some") {
|
||||||
|
const arr = Array.from(collected.value)
|
||||||
|
expect(arr[0].type).toBe(TestEvent.Ping.type)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// RACE 3: the /event-handler shape exactly. With eager subscription, the
|
||||||
|
// bus subscription is alive before Stream.concat ever starts. Publishes
|
||||||
|
// during the prefix consumption window are queued and delivered.
|
||||||
|
it.instance("eager subscribe: Stream.concat(initial, subscribe) delivers publish during prefix", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const bus = yield* Bus.Service
|
||||||
|
const sawInitial = yield* Deferred.make<void>()
|
||||||
|
const sawPublish = yield* Deferred.make<number>()
|
||||||
|
|
||||||
|
type Frame = { marker?: "initial"; value?: number }
|
||||||
|
const subscriptionStream = yield* bus.subscribe(TestEvent.Ping)
|
||||||
|
const handlerStream: Stream.Stream<Frame> = Stream.make({ marker: "initial" } as Frame).pipe(
|
||||||
|
Stream.concat(subscriptionStream.pipe(Stream.map((evt): Frame => ({ value: evt.properties.value })))),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* Stream.runForEach(handlerStream, (frame) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (frame.marker === "initial") {
|
||||||
|
Deferred.doneUnsafe(sawInitial, Effect.void)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (frame.value !== undefined) Deferred.doneUnsafe(sawPublish, Effect.succeed(frame.value))
|
||||||
|
}),
|
||||||
|
).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
|
yield* Deferred.await(sawInitial).pipe(Effect.timeout("1 second"))
|
||||||
|
|
||||||
|
yield* bus.publish(TestEvent.Ping, { value: 7 })
|
||||||
|
|
||||||
|
const got = yield* Deferred.await(sawPublish).pipe(Effect.timeout("1 second"), Effect.option)
|
||||||
|
expect(got._tag).toBe("Some")
|
||||||
|
if (got._tag === "Some") expect(got.value).toBe(7)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("subscribeAll stream sees InstanceDisposed on disposal", () =>
|
it.live("subscribeAll stream sees InstanceDisposed on disposal", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const dir = yield* tmpdirScoped()
|
const dir = yield* tmpdirScoped()
|
||||||
@@ -174,7 +260,7 @@ describe("Bus (Effect-native)", () => {
|
|||||||
yield* Effect.gen(function* () {
|
yield* Effect.gen(function* () {
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
|
|
||||||
yield* Stream.runForEach(bus.subscribeAll(), (evt) =>
|
yield* Stream.runForEach(yield* bus.subscribeAll(), (evt) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (evt.type === TestEvent.Warmup.type) {
|
if (evt.type === TestEvent.Warmup.type) {
|
||||||
yield* ready.open
|
yield* ready.open
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Subprocess integration tests for `opencode run` (non-interactive mode).
|
||||||
|
// These exercise the real CLI binary against a TestLLMServer running in the
|
||||||
|
// same process. See `test/lib/cli-process.ts` for the harness — each test uses
|
||||||
|
// `opencode.run(message, opts?)` to spawn `bun src/index.ts run ...` with
|
||||||
|
// `OPENCODE_CONFIG_CONTENT` providing the test provider config inline.
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { cliIt } from "../../lib/cli-process"
|
||||||
|
|
||||||
|
describe("opencode run (non-interactive subprocess)", () => {
|
||||||
|
// Happy path: prompt completes, output reaches stdout, process exits 0.
|
||||||
|
// If this fails, all the others likely will too — debug here first.
|
||||||
|
cliIt.live(
|
||||||
|
"exits 0 and writes the response to stdout on a successful prompt",
|
||||||
|
({ llm, opencode }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* llm.text("hello from the test llm")
|
||||||
|
const result = yield* opencode.run("say hi")
|
||||||
|
opencode.expectExit(result, 0)
|
||||||
|
expect(result.stdout).toContain("hello from the test llm")
|
||||||
|
}),
|
||||||
|
60_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Regression for #27371: an unknown model used to hang the process forever
|
||||||
|
// waiting on a session.status === idle event that never arrived. The fix
|
||||||
|
// makes the SDK call surface an error promptly so the process exits nonzero.
|
||||||
|
// We assert nonzero exit AND wall-clock under the harness timeout — a hang
|
||||||
|
// would expire the timeout and produce a different (signal-killed) failure.
|
||||||
|
cliIt.live(
|
||||||
|
"exits nonzero promptly when the model is unknown (regression for #27371)",
|
||||||
|
({ opencode }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const result = yield* opencode.run("say hi", {
|
||||||
|
model: "test/nonexistent-model",
|
||||||
|
timeoutMs: 15_000,
|
||||||
|
})
|
||||||
|
expect(result.exitCode).not.toBe(0)
|
||||||
|
expect(result.durationMs).toBeLessThan(15_000)
|
||||||
|
}),
|
||||||
|
30_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Locks in the current behavior: when the LLM stream errors mid-response
|
||||||
|
// (the prompt was accepted, then the upstream provider failed), opencode
|
||||||
|
// emits a session.error event and the process exits 0 today.
|
||||||
|
//
|
||||||
|
// This is debatable — a future cleanup might flip it to exit 1. If you're
|
||||||
|
// changing this expectation, do it deliberately and say so in the PR.
|
||||||
|
cliIt.live(
|
||||||
|
"mid-stream LLM error still exits 0 today (contract lock-in)",
|
||||||
|
({ llm, opencode }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* llm.fail("upstream provider exploded mid-stream")
|
||||||
|
const result = yield* opencode.run("trigger midstream error", { timeoutMs: 30_000 })
|
||||||
|
expect(result.exitCode).toBe(0)
|
||||||
|
}),
|
||||||
|
60_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
// --format json puts one JSON object per line on stdout for each emitted
|
||||||
|
// event. Consumers (CI scripts, tooling) parse this stream. Asserts the
|
||||||
|
// shape so a future event-emit change has to update this expectation.
|
||||||
|
cliIt.live(
|
||||||
|
"--format json emits parseable line-delimited JSON to stdout",
|
||||||
|
({ llm, opencode }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* llm.text("structured output")
|
||||||
|
const result = yield* opencode.run("say hi", { format: "json" })
|
||||||
|
opencode.expectExit(result, 0)
|
||||||
|
|
||||||
|
const events = opencode.parseJsonEvents(result.stdout)
|
||||||
|
expect(events.length).toBeGreaterThan(0)
|
||||||
|
for (const evt of events) {
|
||||||
|
expect(typeof evt.type).toBe("string")
|
||||||
|
expect(typeof evt.sessionID).toBe("string")
|
||||||
|
}
|
||||||
|
// At least one `text` event should appear with the LLM's response.
|
||||||
|
const text = events.find((e) => e.type === "text")
|
||||||
|
expect(text).toBeDefined()
|
||||||
|
}),
|
||||||
|
60_000,
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -96,6 +96,17 @@ function assistant(text: string, phase: StreamCommit["phase"] = "progress"): Str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reasoning(text: string, phase: StreamCommit["phase"] = "progress"): StreamCommit {
|
||||||
|
return {
|
||||||
|
kind: "reasoning",
|
||||||
|
text,
|
||||||
|
phase,
|
||||||
|
source: "reasoning",
|
||||||
|
messageID: "msg-r-1",
|
||||||
|
partID: "part-r-1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function user(text: string): StreamCommit {
|
function user(text: string): StreamCommit {
|
||||||
return {
|
return {
|
||||||
kind: "user",
|
kind: "user",
|
||||||
@@ -392,6 +403,39 @@ test("inserts spacers for new visible groups", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("renders replayed user, reasoning, and assistant output after completion", async () => {
|
||||||
|
const out = await setup()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lines: string[] = []
|
||||||
|
const take = () => {
|
||||||
|
const commits = claim(out.renderer)
|
||||||
|
try {
|
||||||
|
lines.push(...commits.flatMap((commit) => renderRows(commit).flatMap((row) => row.split("\n"))))
|
||||||
|
} finally {
|
||||||
|
destroy(commits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await out.scrollback.append(user("Hello you"))
|
||||||
|
take()
|
||||||
|
await out.scrollback.append(reasoning("Thinking: **Plan**\n\nSay hello.", "progress"))
|
||||||
|
await out.scrollback.complete()
|
||||||
|
take()
|
||||||
|
await out.scrollback.append(assistant("Hello.", "progress"))
|
||||||
|
await out.scrollback.complete()
|
||||||
|
take()
|
||||||
|
|
||||||
|
const output = lines.join("\n")
|
||||||
|
expect(output).toContain("› Hello you")
|
||||||
|
expect(output).toContain("Thinking:")
|
||||||
|
expect(output).toContain("Plan")
|
||||||
|
expect(output).toContain("Hello.")
|
||||||
|
} finally {
|
||||||
|
out.scrollback.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("coalesces same-line tool progress into one snapshot", async () => {
|
test("coalesces same-line tool progress into one snapshot", async () => {
|
||||||
const out = await setup()
|
const out = await setup()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { replaySession } from "@/cli/cmd/run/session-replay"
|
||||||
|
import type { SessionMessages } from "@/cli/cmd/run/session.shared"
|
||||||
|
|
||||||
|
function userMessage(id: string, text: string): SessionMessages[number] {
|
||||||
|
return {
|
||||||
|
info: {
|
||||||
|
id,
|
||||||
|
sessionID: "session-1",
|
||||||
|
role: "user",
|
||||||
|
time: {
|
||||||
|
created: 1,
|
||||||
|
},
|
||||||
|
agent: "build",
|
||||||
|
model: {
|
||||||
|
providerID: "openai",
|
||||||
|
modelID: "gpt-5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
id: `${id}-text`,
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: id,
|
||||||
|
type: "text",
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantInfo(id: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
sessionID: "session-1",
|
||||||
|
role: "assistant" as const,
|
||||||
|
time: {
|
||||||
|
created: 2,
|
||||||
|
},
|
||||||
|
parentID: "msg-user-1",
|
||||||
|
modelID: "gpt-5",
|
||||||
|
providerID: "openai",
|
||||||
|
mode: "chat",
|
||||||
|
agent: "build",
|
||||||
|
path: {
|
||||||
|
cwd: "/tmp",
|
||||||
|
root: "/tmp",
|
||||||
|
},
|
||||||
|
cost: 0,
|
||||||
|
tokens: {
|
||||||
|
input: 1,
|
||||||
|
output: 1,
|
||||||
|
reasoning: 0,
|
||||||
|
cache: {
|
||||||
|
read: 0,
|
||||||
|
write: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantMessage(id: string, text: string): SessionMessages[number] {
|
||||||
|
return {
|
||||||
|
info: assistantInfo(id),
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
id: `${id}-text`,
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: id,
|
||||||
|
type: "text",
|
||||||
|
text,
|
||||||
|
time: {
|
||||||
|
start: 2,
|
||||||
|
end: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runningToolMessage(id: string): SessionMessages[number] {
|
||||||
|
return {
|
||||||
|
info: assistantInfo(id),
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
id: `${id}-tool`,
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: id,
|
||||||
|
type: "tool",
|
||||||
|
callID: `${id}-call`,
|
||||||
|
tool: "bash",
|
||||||
|
state: {
|
||||||
|
status: "running",
|
||||||
|
input: {
|
||||||
|
command: "pwd",
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
start: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("run session replay", () => {
|
||||||
|
test("replays persisted user and assistant history into scrollback commits", () => {
|
||||||
|
const out = replaySession({
|
||||||
|
messages: [
|
||||||
|
userMessage("msg-user-1", "Hello, whats the weather today?"),
|
||||||
|
assistantMessage("msg-1", "What city or ZIP code should I check?"),
|
||||||
|
],
|
||||||
|
permissions: [],
|
||||||
|
questions: [],
|
||||||
|
thinking: true,
|
||||||
|
limits: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(out.commits).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "user",
|
||||||
|
text: "Hello, whats the weather today?",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: "msg-user-1",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "assistant",
|
||||||
|
text: "What city or ZIP code should I check?",
|
||||||
|
phase: "progress",
|
||||||
|
source: "assistant",
|
||||||
|
messageID: "msg-1",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
expect(out.patch).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
phase: "idle",
|
||||||
|
status: "",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps the footer in a running state for resumed active tools", () => {
|
||||||
|
const out = replaySession({
|
||||||
|
messages: [runningToolMessage("msg-1")],
|
||||||
|
permissions: [],
|
||||||
|
questions: [],
|
||||||
|
thinking: true,
|
||||||
|
limits: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(out.patch).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
phase: "running",
|
||||||
|
status: "running bash",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -67,6 +67,22 @@ function idle(sessionID = "session-1") {
|
|||||||
} satisfies SdkEvent
|
} satisfies SdkEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function retry(sessionID: string, attempt: number, message: string) {
|
||||||
|
return {
|
||||||
|
id: `evt-${sessionID}-retry-${attempt}`,
|
||||||
|
type: "session.status",
|
||||||
|
properties: {
|
||||||
|
sessionID,
|
||||||
|
status: {
|
||||||
|
type: "retry",
|
||||||
|
attempt,
|
||||||
|
message,
|
||||||
|
next: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies SdkEvent
|
||||||
|
}
|
||||||
|
|
||||||
function assistant(id: string) {
|
function assistant(id: string) {
|
||||||
return {
|
return {
|
||||||
id: `evt-${id}`,
|
id: `evt-${id}`,
|
||||||
@@ -290,12 +306,12 @@ function toolUpdated(part: SessionToolPart): SdkEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function textDelta(messageID: string, partID: string, delta: string): SdkEvent {
|
function textDelta(messageID: string, partID: string, delta: string, sessionID = "session-1"): SdkEvent {
|
||||||
return {
|
return {
|
||||||
id: `evt-${partID}-delta`,
|
id: `evt-${partID}-delta`,
|
||||||
type: "message.part.delta",
|
type: "message.part.delta",
|
||||||
properties: {
|
properties: {
|
||||||
sessionID: "session-1",
|
sessionID,
|
||||||
messageID,
|
messageID,
|
||||||
partID,
|
partID,
|
||||||
field: "text",
|
field: "text",
|
||||||
@@ -331,6 +347,7 @@ function footer(fn?: (commit: StreamCommit) => void) {
|
|||||||
const commits: StreamCommit[] = []
|
const commits: StreamCommit[] = []
|
||||||
const events: FooterEvent[] = []
|
const events: FooterEvent[] = []
|
||||||
let closed = false
|
let closed = false
|
||||||
|
let idleCalls = 0
|
||||||
|
|
||||||
const api: FooterApi = {
|
const api: FooterApi = {
|
||||||
get isClosed() {
|
get isClosed() {
|
||||||
@@ -346,6 +363,7 @@ function footer(fn?: (commit: StreamCommit) => void) {
|
|||||||
fn?.(next)
|
fn?.(next)
|
||||||
},
|
},
|
||||||
idle() {
|
idle() {
|
||||||
|
idleCalls += 1
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
@@ -356,7 +374,14 @@ function footer(fn?: (commit: StreamCommit) => void) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return { api, commits, events }
|
return {
|
||||||
|
api,
|
||||||
|
commits,
|
||||||
|
events,
|
||||||
|
get idleCalls() {
|
||||||
|
return idleCalls
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sdk(
|
function sdk(
|
||||||
@@ -398,6 +423,355 @@ function sdk(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("run stream transport", () => {
|
describe("run stream transport", () => {
|
||||||
|
test("does not replay persisted main-session history during bootstrap by default", async () => {
|
||||||
|
const src = eventFeed()
|
||||||
|
const ui = footer()
|
||||||
|
const transport = await createSessionTransport({
|
||||||
|
sdk: sdk({
|
||||||
|
stream: src.stream,
|
||||||
|
messages: async ({ sessionID }) =>
|
||||||
|
sessionID === "session-1"
|
||||||
|
? ok([
|
||||||
|
assistantMessage({
|
||||||
|
sessionID: "session-1",
|
||||||
|
id: "msg-1",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
...textPart("text-1", "msg-1", "Hello."),
|
||||||
|
time: {
|
||||||
|
start: 1,
|
||||||
|
end: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
: ok([]),
|
||||||
|
}),
|
||||||
|
sessionID: "session-1",
|
||||||
|
thinking: true,
|
||||||
|
limits: () => ({}),
|
||||||
|
footer: ui.api,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(ui.commits).toEqual([])
|
||||||
|
expect(ui.idleCalls).toBe(0)
|
||||||
|
} finally {
|
||||||
|
src.close()
|
||||||
|
await transport.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("replays persisted main-session history during bootstrap when enabled", async () => {
|
||||||
|
const src = eventFeed()
|
||||||
|
const ui = footer()
|
||||||
|
const transport = await createSessionTransport({
|
||||||
|
sdk: sdk({
|
||||||
|
stream: src.stream,
|
||||||
|
messages: async ({ sessionID }) =>
|
||||||
|
sessionID === "session-1"
|
||||||
|
? ok([
|
||||||
|
assistantMessage({
|
||||||
|
sessionID: "session-1",
|
||||||
|
id: "msg-1",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
...textPart("text-1", "msg-1", "Hello."),
|
||||||
|
time: {
|
||||||
|
start: 1,
|
||||||
|
end: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
: ok([]),
|
||||||
|
}),
|
||||||
|
sessionID: "session-1",
|
||||||
|
thinking: true,
|
||||||
|
replay: true,
|
||||||
|
limits: () => ({}),
|
||||||
|
footer: ui.api,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitFor(() => ui.commits.find((item) => item.kind === "assistant" && item.text === "Hello."))
|
||||||
|
expect(ui.idleCalls).toBeGreaterThan(0)
|
||||||
|
} finally {
|
||||||
|
src.close()
|
||||||
|
await transport.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("caps replayed bootstrap history to the configured number of messages", async () => {
|
||||||
|
const src = eventFeed()
|
||||||
|
const ui = footer()
|
||||||
|
const transport = await createSessionTransport({
|
||||||
|
sdk: sdk({
|
||||||
|
stream: src.stream,
|
||||||
|
messages: async ({ sessionID }) =>
|
||||||
|
ok(
|
||||||
|
sessionID === "session-1"
|
||||||
|
? [
|
||||||
|
assistantMessage({
|
||||||
|
sessionID: "session-1",
|
||||||
|
id: "msg-1",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
...textPart("text-1", "msg-1", "Hello."),
|
||||||
|
time: {
|
||||||
|
start: 1,
|
||||||
|
end: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
assistantMessage({
|
||||||
|
sessionID: "session-1",
|
||||||
|
id: "msg-2",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
...textPart("text-2", "msg-2", "World."),
|
||||||
|
time: {
|
||||||
|
start: 3,
|
||||||
|
end: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
sessionID: "session-1",
|
||||||
|
thinking: true,
|
||||||
|
replay: true,
|
||||||
|
replayLimit: 1,
|
||||||
|
limits: () => ({}),
|
||||||
|
footer: ui.api,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitFor(() => (ui.commits.length > 0 ? ui.commits : undefined))
|
||||||
|
expect(ui.commits.filter((item) => item.kind === "assistant")).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
text: "World.",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
src.close()
|
||||||
|
await transport.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips buffered pre-bootstrap deltas already covered by replay history", async () => {
|
||||||
|
const src = eventFeed()
|
||||||
|
const ui = footer()
|
||||||
|
const gate = defer<void>()
|
||||||
|
let transport: Awaited<ReturnType<typeof createSessionTransport>> | undefined
|
||||||
|
const task = createSessionTransport({
|
||||||
|
sdk: sdk({
|
||||||
|
stream: src.stream,
|
||||||
|
messages: async ({ sessionID }) => {
|
||||||
|
if (sessionID !== "session-1") {
|
||||||
|
return ok([])
|
||||||
|
}
|
||||||
|
|
||||||
|
await gate.promise
|
||||||
|
return ok([
|
||||||
|
assistantMessage({
|
||||||
|
sessionID: "session-1",
|
||||||
|
id: "msg-1",
|
||||||
|
parts: [textPart("text-1", "msg-1", "Hello")],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
sessionID: "session-1",
|
||||||
|
thinking: true,
|
||||||
|
replay: true,
|
||||||
|
limits: () => ({}),
|
||||||
|
footer: ui.api,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.resolve()
|
||||||
|
src.push(textDelta("msg-1", "text-1", "lo"))
|
||||||
|
gate.resolve()
|
||||||
|
transport = await task
|
||||||
|
|
||||||
|
await waitFor(() => (ui.commits.length > 0 ? ui.commits : undefined))
|
||||||
|
await Bun.sleep(20)
|
||||||
|
expect(ui.commits.filter((item) => item.kind === "assistant")).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
text: "Hello",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
src.close()
|
||||||
|
await transport?.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("applies buffered pre-bootstrap deltas not yet persisted", async () => {
|
||||||
|
const src = eventFeed()
|
||||||
|
const ui = footer()
|
||||||
|
const gate = defer<void>()
|
||||||
|
let transport: Awaited<ReturnType<typeof createSessionTransport>> | undefined
|
||||||
|
const task = createSessionTransport({
|
||||||
|
sdk: sdk({
|
||||||
|
stream: src.stream,
|
||||||
|
messages: async ({ sessionID }) => {
|
||||||
|
if (sessionID !== "session-1") {
|
||||||
|
return ok([])
|
||||||
|
}
|
||||||
|
|
||||||
|
await gate.promise
|
||||||
|
return ok([
|
||||||
|
assistantMessage({
|
||||||
|
sessionID: "session-1",
|
||||||
|
id: "msg-1",
|
||||||
|
parts: [textPart("text-1", "msg-1", "")],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
sessionID: "session-1",
|
||||||
|
thinking: true,
|
||||||
|
replay: true,
|
||||||
|
limits: () => ({}),
|
||||||
|
footer: ui.api,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.resolve()
|
||||||
|
src.push(textDelta("msg-1", "text-1", "Hello"))
|
||||||
|
gate.resolve()
|
||||||
|
transport = await task
|
||||||
|
|
||||||
|
await waitFor(() => (ui.commits.length > 0 ? ui.commits : undefined))
|
||||||
|
await Bun.sleep(20)
|
||||||
|
expect(ui.commits.filter((item) => item.kind === "assistant")).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
text: "Hello",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
src.close()
|
||||||
|
await transport?.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves running footer state for resumed active sessions", async () => {
|
||||||
|
const src = eventFeed()
|
||||||
|
const ui = footer()
|
||||||
|
const transport = await createSessionTransport({
|
||||||
|
sdk: sdk({
|
||||||
|
stream: src.stream,
|
||||||
|
messages: async ({ sessionID }) =>
|
||||||
|
sessionID === "session-1"
|
||||||
|
? ok([
|
||||||
|
assistantMessage({
|
||||||
|
sessionID: "session-1",
|
||||||
|
id: "msg-1",
|
||||||
|
parts: [
|
||||||
|
runningTool({
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: "msg-1",
|
||||||
|
id: "bash-1",
|
||||||
|
callID: "call-1",
|
||||||
|
tool: "bash",
|
||||||
|
body: {
|
||||||
|
command: "pwd",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
: ok([]),
|
||||||
|
}),
|
||||||
|
sessionID: "session-1",
|
||||||
|
thinking: true,
|
||||||
|
replay: true,
|
||||||
|
limits: () => ({}),
|
||||||
|
footer: ui.api,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const patch = await waitFor(() => {
|
||||||
|
const item = ui.events.findLast((event) => event.type === "stream.patch")
|
||||||
|
return item?.type === "stream.patch" ? item.patch : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(patch).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
phase: "running",
|
||||||
|
status: "running bash",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
src.close()
|
||||||
|
await transport.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("drops completed historical subagent tabs during bootstrap", async () => {
|
||||||
|
const src = eventFeed()
|
||||||
|
const ui = footer()
|
||||||
|
const transport = await createSessionTransport({
|
||||||
|
sdk: sdk({
|
||||||
|
stream: src.stream,
|
||||||
|
messages: async ({ sessionID }) => {
|
||||||
|
if (sessionID !== "session-1") {
|
||||||
|
return ok([])
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok([
|
||||||
|
assistantMessage({
|
||||||
|
sessionID: "session-1",
|
||||||
|
id: "msg-1",
|
||||||
|
parts: [
|
||||||
|
completedTool({
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: "msg-1",
|
||||||
|
id: "task-1",
|
||||||
|
callID: "call-1",
|
||||||
|
tool: "task",
|
||||||
|
body: {
|
||||||
|
description: "Explore run folder",
|
||||||
|
subagent_type: "explore",
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
sessionId: "child-1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
children: async () => ok([child("child-1")]),
|
||||||
|
}),
|
||||||
|
sessionID: "session-1",
|
||||||
|
thinking: true,
|
||||||
|
limits: () => ({}),
|
||||||
|
footer: ui.api,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = await waitFor(() => {
|
||||||
|
const item = ui.events.findLast((event) => event.type === "stream.subagent")
|
||||||
|
return item?.type === "stream.subagent" ? item.state : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(state.tabs).toEqual([])
|
||||||
|
expect(state.details).toEqual({})
|
||||||
|
} finally {
|
||||||
|
src.close()
|
||||||
|
await transport.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("bootstraps child tabs and resumed blocker input", async () => {
|
test("bootstraps child tabs and resumed blocker input", async () => {
|
||||||
const src = eventFeed()
|
const src = eventFeed()
|
||||||
const ui = footer()
|
const ui = footer()
|
||||||
@@ -487,7 +861,7 @@ describe("run stream transport", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
sessionID: "child-1",
|
sessionID: "child-1",
|
||||||
label: "Explore",
|
label: "Explore",
|
||||||
description: "Explore run folder",
|
description: "Pending permission",
|
||||||
status: "running",
|
status: "running",
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
@@ -569,7 +943,7 @@ describe("run stream transport", () => {
|
|||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
id: "msg-1",
|
id: "msg-1",
|
||||||
parts: [
|
parts: [
|
||||||
completedTool({
|
runningTool({
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
messageID: "msg-1",
|
messageID: "msg-1",
|
||||||
id: "task-1",
|
id: "task-1",
|
||||||
@@ -711,6 +1085,109 @@ describe("run stream transport", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("replays child events buffered during bootstrap once the tab is known", async () => {
|
||||||
|
const global = globalFeed()
|
||||||
|
const ui = footer()
|
||||||
|
const gate = defer<void>()
|
||||||
|
let transport: Awaited<ReturnType<typeof createSessionTransport>> | undefined
|
||||||
|
const task = createSessionTransport({
|
||||||
|
sdk: sdk({
|
||||||
|
globalStream: global.stream,
|
||||||
|
messages: async ({ sessionID }) => {
|
||||||
|
if (sessionID !== "session-1") {
|
||||||
|
return ok([])
|
||||||
|
}
|
||||||
|
|
||||||
|
await gate.promise
|
||||||
|
return ok([])
|
||||||
|
},
|
||||||
|
children: async () => ok([]),
|
||||||
|
}),
|
||||||
|
sessionID: "session-1",
|
||||||
|
thinking: true,
|
||||||
|
limits: () => ({}),
|
||||||
|
footer: ui.api,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.resolve()
|
||||||
|
global.push(globalEvent(retry("child-1", 1, "retry child")))
|
||||||
|
global.push(
|
||||||
|
globalEvent({
|
||||||
|
id: "evt-child-message",
|
||||||
|
type: "message.updated",
|
||||||
|
properties: {
|
||||||
|
sessionID: "child-1",
|
||||||
|
info: assistantMessage({
|
||||||
|
sessionID: "child-1",
|
||||||
|
id: "msg-child-1",
|
||||||
|
parts: [],
|
||||||
|
}).info,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
global.push(globalEvent(textUpdated(textPart("txt-child-1", "msg-child-1", "", "child-1"))))
|
||||||
|
global.push(globalEvent(textDelta("msg-child-1", "txt-child-1", "Hello", "child-1")))
|
||||||
|
global.push(
|
||||||
|
globalEvent(
|
||||||
|
toolUpdated(
|
||||||
|
runningTool({
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: "msg-1",
|
||||||
|
id: "task-1",
|
||||||
|
callID: "call-1",
|
||||||
|
tool: "task",
|
||||||
|
body: {
|
||||||
|
description: "Explore run.ts",
|
||||||
|
subagent_type: "explore",
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
sessionId: "child-1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
gate.resolve()
|
||||||
|
transport = await task
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const item = ui.events.findLast((event) => event.type === "stream.subagent")
|
||||||
|
return item?.type === "stream.subagent" && item.state.tabs.some((tab) => tab.sessionID === "child-1")
|
||||||
|
? item
|
||||||
|
: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
transport.selectSubagent("child-1")
|
||||||
|
|
||||||
|
const detail = await waitFor(() => {
|
||||||
|
const item = ui.events.findLast((event) => event.type === "stream.subagent")
|
||||||
|
const next = item?.type === "stream.subagent" ? item.state.details["child-1"] : undefined
|
||||||
|
return next?.commits.some((commit) => commit.kind === "error" && commit.text === "retry child") &&
|
||||||
|
next.commits.some((commit) => commit.kind === "assistant" && commit.text === "Hello")
|
||||||
|
? next
|
||||||
|
: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(detail).toEqual({
|
||||||
|
sessionID: "child-1",
|
||||||
|
commits: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "error",
|
||||||
|
text: "retry child",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "assistant",
|
||||||
|
text: "Hello",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
global.close()
|
||||||
|
await transport?.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("streams selected subagent output from global events while it is running", async () => {
|
test("streams selected subagent output from global events while it is running", async () => {
|
||||||
const global = globalFeed()
|
const global = globalFeed()
|
||||||
const ui = footer()
|
const ui = footer()
|
||||||
|
|||||||
@@ -205,10 +205,10 @@ test(
|
|||||||
const { config, restore } = mockTuiRuntime(tmp.path, [tmp.extra.spec])
|
const { config, restore } = mockTuiRuntime(tmp.path, [tmp.extra.spec])
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config, disposeTimeoutMs: 25 })
|
||||||
|
|
||||||
const done = await new Promise<string>((resolve) => {
|
const done = await new Promise<string>((resolve) => {
|
||||||
const timer = setTimeout(() => resolve("timeout"), 7000)
|
const timer = setTimeout(() => resolve("timeout"), 500)
|
||||||
void TuiPluginRuntime.dispose().then(() => {
|
void TuiPluginRuntime.dispose().then(() => {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
resolve("done")
|
resolve("done")
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ test("replace slot mounts plugin content once", async () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
await testRender(() => <App />)
|
const app = await testRender(() => <App />)
|
||||||
|
try {
|
||||||
expect(mounts).toBe(1)
|
expect(mounts).toBe(1)
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect, describe, mock, afterEach, beforeEach } from "bun:test"
|
import { test, expect, describe, mock, afterEach, beforeEach } from "bun:test"
|
||||||
import { Effect, Layer, Option } from "effect"
|
import { Effect, Exit, Layer, Option } from "effect"
|
||||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { ConfigManaged } from "@/config/managed"
|
import { ConfigManaged } from "@/config/managed"
|
||||||
@@ -13,7 +13,7 @@ import { Account } from "../../src/account/account"
|
|||||||
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
|
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Env } from "../../src/env"
|
import { Env } from "../../src/env"
|
||||||
import { provideTestInstance, provideTmpdirInstance, withTestInstance } from "../fixture/fixture"
|
import { provideTestInstance, provideTmpdirInstance, TestInstance, withTestInstance } from "../fixture/fixture"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
@@ -68,13 +68,6 @@ const load = (ctx: InstanceContext) =>
|
|||||||
Effect.runPromise(
|
Effect.runPromise(
|
||||||
Config.Service.use((svc) => provideCurrentInstance(svc.get(), ctx)).pipe(Effect.scoped, Effect.provide(layer)),
|
Config.Service.use((svc) => provideCurrentInstance(svc.get(), ctx)).pipe(Effect.scoped, Effect.provide(layer)),
|
||||||
)
|
)
|
||||||
const save = (config: Config.Info, ctx: InstanceContext) =>
|
|
||||||
Effect.runPromise(
|
|
||||||
Config.Service.use((svc) => provideCurrentInstance(svc.update(config), ctx)).pipe(
|
|
||||||
Effect.scoped,
|
|
||||||
Effect.provide(layer),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const saveGlobal = (config: Config.Info) =>
|
const saveGlobal = (config: Config.Info) =>
|
||||||
Effect.runPromise(
|
Effect.runPromise(
|
||||||
Config.Service.use((svc) => svc.updateGlobal(config)).pipe(
|
Config.Service.use((svc) => svc.updateGlobal(config)).pipe(
|
||||||
@@ -94,14 +87,6 @@ const listDirs = (ctx: InstanceContext) =>
|
|||||||
Effect.provide(layer),
|
Effect.provide(layer),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const ready = (ctx: InstanceContext) =>
|
|
||||||
Effect.runPromise(
|
|
||||||
Config.Service.use((svc) => provideCurrentInstance(svc.waitForDependencies(), ctx)).pipe(
|
|
||||||
Effect.scoped,
|
|
||||||
Effect.provide(layer),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Get managed config directory from environment (set in preload.ts)
|
// Get managed config directory from environment (set in preload.ts)
|
||||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
||||||
|
|
||||||
@@ -123,6 +108,25 @@ async function writeConfig(dir: string, config: object, name = "opencode.json")
|
|||||||
await Filesystem.write(path.join(dir, name), JSON.stringify(config))
|
await Filesystem.write(path.join(dir, name), JSON.stringify(config))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const writeConfigEffect = (dir: string, config: object, name = "opencode.json") =>
|
||||||
|
Effect.promise(() => writeConfig(dir, config, name))
|
||||||
|
|
||||||
|
function withProcessEnv<A, E, R>(key: string, value: string, effect: Effect.Effect<A, E, R>) {
|
||||||
|
return Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const original = process.env[key]
|
||||||
|
process.env[key] = value
|
||||||
|
return original
|
||||||
|
}),
|
||||||
|
() => effect,
|
||||||
|
(original) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (original !== undefined) process.env[key] = original
|
||||||
|
else delete process.env[key]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function check(map: (dir: string) => string) {
|
async function check(map: (dir: string) => string) {
|
||||||
if (process.platform !== "win32") return
|
if (process.platform !== "win32") return
|
||||||
await using globalTmp = await tmpdir()
|
await using globalTmp = await tmpdir()
|
||||||
@@ -210,67 +214,42 @@ test("does not create global config when OPENCODE_CONFIG_DIR is set", async () =
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("loads JSON config file", async () => {
|
it.instance(
|
||||||
await using tmp = await tmpdir({
|
"loads JSON config file",
|
||||||
init: async (dir) => {
|
Effect.gen(function* () {
|
||||||
await writeConfig(dir, {
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
model: "test/model",
|
|
||||||
username: "testuser",
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
const config = await load(ctx)
|
|
||||||
expect(config.model).toBe("test/model")
|
expect(config.model).toBe("test/model")
|
||||||
expect(config.username).toBe("testuser")
|
expect(config.username).toBe("testuser")
|
||||||
},
|
}),
|
||||||
})
|
{ config: { model: "test/model", username: "testuser" } },
|
||||||
})
|
)
|
||||||
|
|
||||||
test("loads shell config field", async () => {
|
it.instance(
|
||||||
await using tmp = await tmpdir({
|
"loads shell config field",
|
||||||
init: async (dir) => {
|
Effect.gen(function* () {
|
||||||
await writeConfig(dir, {
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
shell: "bash",
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
const config = await load(ctx)
|
|
||||||
expect(config.shell).toBe("bash")
|
expect(config.shell).toBe("bash")
|
||||||
},
|
}),
|
||||||
})
|
{ config: { shell: "bash" } },
|
||||||
})
|
)
|
||||||
|
|
||||||
test("updates config and preserves empty shell sentinel", async () => {
|
it.instance("updates config and preserves empty shell sentinel", () =>
|
||||||
await using tmp = await tmpdir({
|
Effect.gen(function* () {
|
||||||
init: async (dir) => {
|
const test = yield* TestInstance
|
||||||
await writeConfig(
|
yield* writeConfigEffect(
|
||||||
dir,
|
test.directory,
|
||||||
{
|
{ $schema: "https://opencode.ai/config.json", shell: "bash" },
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
shell: "bash",
|
|
||||||
},
|
|
||||||
"config.json",
|
"config.json",
|
||||||
)
|
)
|
||||||
},
|
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
await save({ shell: "" }, ctx)
|
|
||||||
|
|
||||||
const writtenConfig = await Filesystem.readJson<{ shell?: string }>(path.join(tmp.path, "config.json"))
|
yield* Config.Service.use((svc) => svc.update(ConfigParse.schema(Config.Info, { shell: "" }, "test:config")))
|
||||||
|
|
||||||
|
const writtenConfig = yield* Effect.promise(() =>
|
||||||
|
Filesystem.readJson<{ shell?: string }>(path.join(test.directory, "config.json")),
|
||||||
|
)
|
||||||
expect(writtenConfig.shell).toBe("")
|
expect(writtenConfig.shell).toBe("")
|
||||||
},
|
}),
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("updates global config and omits empty shell key in json", async () => {
|
test("updates global config and omits empty shell key in json", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
@@ -330,41 +309,23 @@ test("updates global config and omits empty shell key in jsonc", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("loads formatter boolean config", async () => {
|
it.instance(
|
||||||
await using tmp = await tmpdir({
|
"loads formatter boolean config",
|
||||||
init: async (dir) => {
|
Effect.gen(function* () {
|
||||||
await writeConfig(dir, {
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
formatter: true,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
const config = await load(ctx)
|
|
||||||
expect(config.formatter).toBe(true)
|
expect(config.formatter).toBe(true)
|
||||||
},
|
}),
|
||||||
})
|
{ config: { formatter: true } },
|
||||||
})
|
)
|
||||||
|
|
||||||
test("loads lsp boolean config", async () => {
|
it.instance(
|
||||||
await using tmp = await tmpdir({
|
"loads lsp boolean config",
|
||||||
init: async (dir) => {
|
Effect.gen(function* () {
|
||||||
await writeConfig(dir, {
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
lsp: true,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
const config = await load(ctx)
|
|
||||||
expect(config.lsp).toBe(true)
|
expect(config.lsp).toBe(true)
|
||||||
},
|
}),
|
||||||
})
|
{ config: { lsp: true } },
|
||||||
})
|
)
|
||||||
|
|
||||||
test("loads project config from Git Bash and MSYS2 paths on Windows", async () => {
|
test("loads project config from Git Bash and MSYS2 paths on Windows", async () => {
|
||||||
// Git Bash and MSYS2 both use /<drive>/... paths on Windows.
|
// Git Bash and MSYS2 both use /<drive>/... paths on Windows.
|
||||||
@@ -405,35 +366,31 @@ test("ignores legacy tui keys in opencode config", async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("loads JSONC config file", async () => {
|
it.instance("loads JSONC config file", () =>
|
||||||
await using tmp = await tmpdir({
|
Effect.gen(function* () {
|
||||||
init: async (dir) => {
|
const test = yield* TestInstance
|
||||||
await Filesystem.write(
|
yield* Effect.promise(() =>
|
||||||
path.join(dir, "opencode.jsonc"),
|
Filesystem.write(
|
||||||
|
path.join(test.directory, "opencode.jsonc"),
|
||||||
`{
|
`{
|
||||||
// This is a comment
|
// This is a comment
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"model": "test/model",
|
"model": "test/model",
|
||||||
"username": "testuser"
|
"username": "testuser"
|
||||||
}`,
|
}`,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
},
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
const config = await load(ctx)
|
|
||||||
expect(config.model).toBe("test/model")
|
expect(config.model).toBe("test/model")
|
||||||
expect(config.username).toBe("testuser")
|
expect(config.username).toBe("testuser")
|
||||||
},
|
}),
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("jsonc overrides json in the same directory", async () => {
|
it.instance("jsonc overrides json in the same directory", () =>
|
||||||
await using tmp = await tmpdir({
|
Effect.gen(function* () {
|
||||||
init: async (dir) => {
|
const test = yield* TestInstance
|
||||||
await writeConfig(
|
yield* writeConfigEffect(
|
||||||
dir,
|
test.directory,
|
||||||
{
|
{
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
model: "base",
|
model: "base",
|
||||||
@@ -441,88 +398,86 @@ test("jsonc overrides json in the same directory", async () => {
|
|||||||
},
|
},
|
||||||
"opencode.jsonc",
|
"opencode.jsonc",
|
||||||
)
|
)
|
||||||
await writeConfig(dir, {
|
yield* writeConfigEffect(test.directory, {
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
model: "override",
|
model: "override",
|
||||||
})
|
})
|
||||||
},
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
const config = await load(ctx)
|
|
||||||
expect(config.model).toBe("base")
|
expect(config.model).toBe("base")
|
||||||
expect(config.username).toBe("base")
|
expect(config.username).toBe("base")
|
||||||
},
|
}),
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("handles environment variable substitution", async () => {
|
it.instance("handles environment variable substitution", () =>
|
||||||
const originalEnv = process.env["TEST_VAR"]
|
withProcessEnv(
|
||||||
process.env["TEST_VAR"] = "test-user"
|
"TEST_VAR",
|
||||||
|
"test-user",
|
||||||
try {
|
Effect.gen(function* () {
|
||||||
await using tmp = await tmpdir({
|
const test = yield* TestInstance
|
||||||
init: async (dir) => {
|
yield* writeConfigEffect(test.directory, {
|
||||||
await writeConfig(dir, {
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
username: "{env:TEST_VAR}",
|
username: "{env:TEST_VAR}",
|
||||||
})
|
})
|
||||||
},
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
const config = await load(ctx)
|
|
||||||
expect(config.username).toBe("test-user")
|
expect(config.username).toBe("test-user")
|
||||||
},
|
}),
|
||||||
})
|
),
|
||||||
} finally {
|
)
|
||||||
if (originalEnv !== undefined) {
|
|
||||||
process.env["TEST_VAR"] = originalEnv
|
|
||||||
} else {
|
|
||||||
delete process.env["TEST_VAR"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves env variables when adding $schema to config", async () => {
|
it.instance("preserves env variables when adding $schema to config", () =>
|
||||||
const originalEnv = process.env["PRESERVE_VAR"]
|
withProcessEnv(
|
||||||
process.env["PRESERVE_VAR"] = "secret_value"
|
"PRESERVE_VAR",
|
||||||
|
"secret_value",
|
||||||
try {
|
Effect.gen(function* () {
|
||||||
await using tmp = await tmpdir({
|
const test = yield* TestInstance
|
||||||
init: async (dir) => {
|
|
||||||
// Config without $schema - should trigger auto-add
|
// Config without $schema - should trigger auto-add
|
||||||
await Filesystem.write(
|
yield* Effect.promise(() =>
|
||||||
path.join(dir, "opencode.json"),
|
Filesystem.write(
|
||||||
|
path.join(test.directory, "opencode.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
username: "{env:PRESERVE_VAR}",
|
username: "{env:PRESERVE_VAR}",
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
},
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
const config = await load(ctx)
|
|
||||||
expect(config.username).toBe("secret_value")
|
expect(config.username).toBe("secret_value")
|
||||||
|
|
||||||
// Read the file to verify the env variable was preserved
|
// Read the file to verify the env variable was preserved
|
||||||
const content = await Filesystem.readText(path.join(tmp.path, "opencode.json"))
|
const content = yield* Effect.promise(() => Filesystem.readText(path.join(test.directory, "opencode.json")))
|
||||||
expect(content).toContain("{env:PRESERVE_VAR}")
|
expect(content).toContain("{env:PRESERVE_VAR}")
|
||||||
expect(content).not.toContain("secret_value")
|
expect(content).not.toContain("secret_value")
|
||||||
expect(content).toContain("$schema")
|
expect(content).toContain("$schema")
|
||||||
},
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.instance("handles file inclusion substitution", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const test = yield* TestInstance
|
||||||
|
yield* Effect.promise(() => Filesystem.write(path.join(test.directory, "included.txt"), "test-user"))
|
||||||
|
yield* writeConfigEffect(test.directory, {
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
username: "{file:included.txt}",
|
||||||
})
|
})
|
||||||
} finally {
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
if (originalEnv !== undefined) {
|
expect(config.username).toBe("test-user")
|
||||||
process.env["PRESERVE_VAR"] = originalEnv
|
}),
|
||||||
} else {
|
)
|
||||||
delete process.env["PRESERVE_VAR"]
|
|
||||||
}
|
it.instance("handles file inclusion with replacement tokens", () =>
|
||||||
}
|
Effect.gen(function* () {
|
||||||
})
|
const test = yield* TestInstance
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
Filesystem.write(path.join(test.directory, "included.md"), "const out = await Bun.$`echo hi`"),
|
||||||
|
)
|
||||||
|
yield* writeConfigEffect(test.directory, {
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
username: "{file:included.md}",
|
||||||
|
})
|
||||||
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
|
expect(config.username).toBe("const out = await Bun.$`echo hi`")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("resolves env templates in account config with account token", async () => {
|
test("resolves env templates in account config with account token", async () => {
|
||||||
const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
|
const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
|
||||||
@@ -589,80 +544,31 @@ test("resolves env templates in account config with account token", async () =>
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("handles file inclusion substitution", async () => {
|
it.instance("validates config schema and throws on invalid fields", () =>
|
||||||
await using tmp = await tmpdir({
|
Effect.gen(function* () {
|
||||||
init: async (dir) => {
|
const test = yield* TestInstance
|
||||||
await Filesystem.write(path.join(dir, "included.txt"), "test-user")
|
yield* writeConfigEffect(test.directory, {
|
||||||
await writeConfig(dir, {
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
username: "{file:included.txt}",
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
const config = await load(ctx)
|
|
||||||
expect(config.username).toBe("test-user")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("handles file inclusion with replacement tokens", async () => {
|
|
||||||
await using tmp = await tmpdir({
|
|
||||||
init: async (dir) => {
|
|
||||||
await Filesystem.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`")
|
|
||||||
await writeConfig(dir, {
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
username: "{file:included.md}",
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
const config = await load(ctx)
|
|
||||||
expect(config.username).toBe("const out = await Bun.$`echo hi`")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("validates config schema and throws on invalid fields", async () => {
|
|
||||||
await using tmp = await tmpdir({
|
|
||||||
init: async (dir) => {
|
|
||||||
await writeConfig(dir, {
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
invalid_field: "should cause error",
|
invalid_field: "should cause error",
|
||||||
})
|
})
|
||||||
},
|
const exit = yield* Config.Service.use((svc) => svc.get()).pipe(Effect.exit)
|
||||||
})
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
await provideTestInstance({
|
}),
|
||||||
directory: tmp.path,
|
)
|
||||||
fn: async (ctx) => {
|
|
||||||
// Strict schema should throw an error for invalid fields
|
|
||||||
await expect(load(ctx)).rejects.toThrow()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("throws error for invalid JSON", async () => {
|
it.instance("throws error for invalid JSON", () =>
|
||||||
await using tmp = await tmpdir({
|
Effect.gen(function* () {
|
||||||
init: async (dir) => {
|
const test = yield* TestInstance
|
||||||
await Filesystem.write(path.join(dir, "opencode.json"), "{ invalid json }")
|
yield* Effect.promise(() => Filesystem.write(path.join(test.directory, "opencode.json"), "{ invalid json }"))
|
||||||
},
|
const exit = yield* Config.Service.use((svc) => svc.get()).pipe(Effect.exit)
|
||||||
})
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
await provideTestInstance({
|
}),
|
||||||
directory: tmp.path,
|
)
|
||||||
fn: async (ctx) => {
|
|
||||||
await expect(load(ctx)).rejects.toThrow()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("handles agent configuration", async () => {
|
it.instance("handles agent configuration", () =>
|
||||||
await using tmp = await tmpdir({
|
Effect.gen(function* () {
|
||||||
init: async (dir) => {
|
const test = yield* TestInstance
|
||||||
await writeConfig(dir, {
|
yield* writeConfigEffect(test.directory, {
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
agent: {
|
agent: {
|
||||||
test_agent: {
|
test_agent: {
|
||||||
@@ -672,12 +578,7 @@ test("handles agent configuration", async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
const config = await load(ctx)
|
|
||||||
expect(config.agent?.["test_agent"]).toEqual(
|
expect(config.agent?.["test_agent"]).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
model: "test/model",
|
model: "test/model",
|
||||||
@@ -685,14 +586,13 @@ test("handles agent configuration", async () => {
|
|||||||
description: "test agent",
|
description: "test agent",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
}),
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("treats agent variant as model-scoped setting (not provider option)", async () => {
|
it.instance("treats agent variant as model-scoped setting (not provider option)", () =>
|
||||||
await using tmp = await tmpdir({
|
Effect.gen(function* () {
|
||||||
init: async (dir) => {
|
const test = yield* TestInstance
|
||||||
await writeConfig(dir, {
|
yield* writeConfigEffect(test.directory, {
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
agent: {
|
agent: {
|
||||||
test_agent: {
|
test_agent: {
|
||||||
@@ -702,13 +602,7 @@ test("treats agent variant as model-scoped setting (not provider option)", async
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
})
|
|
||||||
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
const config = await load(ctx)
|
|
||||||
const agent = config.agent?.["test_agent"]
|
const agent = config.agent?.["test_agent"]
|
||||||
|
|
||||||
expect(agent?.variant).toBe("xhigh")
|
expect(agent?.variant).toBe("xhigh")
|
||||||
@@ -716,14 +610,13 @@ test("treats agent variant as model-scoped setting (not provider option)", async
|
|||||||
max_tokens: 123,
|
max_tokens: 123,
|
||||||
})
|
})
|
||||||
expect(agent?.options).not.toHaveProperty("variant")
|
expect(agent?.options).not.toHaveProperty("variant")
|
||||||
},
|
}),
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("handles command configuration", async () => {
|
it.instance("handles command configuration", () =>
|
||||||
await using tmp = await tmpdir({
|
Effect.gen(function* () {
|
||||||
init: async (dir) => {
|
const test = yield* TestInstance
|
||||||
await writeConfig(dir, {
|
yield* writeConfigEffect(test.directory, {
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
command: {
|
command: {
|
||||||
test_command: {
|
test_command: {
|
||||||
@@ -733,49 +626,32 @@ test("handles command configuration", async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
const config = await load(ctx)
|
|
||||||
expect(config.command?.["test_command"]).toEqual({
|
expect(config.command?.["test_command"]).toEqual({
|
||||||
template: "test template",
|
template: "test template",
|
||||||
description: "test command",
|
description: "test command",
|
||||||
agent: "test_agent",
|
agent: "test_agent",
|
||||||
})
|
})
|
||||||
},
|
}),
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("migrates autoshare to share field", async () => {
|
it.instance("migrates autoshare to share field", () =>
|
||||||
await using tmp = await tmpdir({
|
Effect.gen(function* () {
|
||||||
init: async (dir) => {
|
const test = yield* TestInstance
|
||||||
await Filesystem.write(
|
yield* writeConfigEffect(test.directory, {
|
||||||
path.join(dir, "opencode.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
autoshare: true,
|
autoshare: true,
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
await withTestInstance({
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
const config = await load(ctx)
|
|
||||||
expect(config.share).toBe("auto")
|
expect(config.share).toBe("auto")
|
||||||
expect(config.autoshare).toBe(true)
|
expect(config.autoshare).toBe(true)
|
||||||
},
|
}),
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("migrates mode field to agent field", async () => {
|
it.instance("migrates mode field to agent field", () =>
|
||||||
await using tmp = await tmpdir({
|
Effect.gen(function* () {
|
||||||
init: async (dir) => {
|
const test = yield* TestInstance
|
||||||
await Filesystem.write(
|
yield* writeConfigEffect(test.directory, {
|
||||||
path.join(dir, "opencode.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
mode: {
|
mode: {
|
||||||
test_mode: {
|
test_mode: {
|
||||||
@@ -783,14 +659,8 @@ test("migrates mode field to agent field", async () => {
|
|||||||
temperature: 0.5,
|
temperature: 0.5,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
await withTestInstance({
|
const config = yield* Config.Service.use((svc) => svc.get())
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
const config = await load(ctx)
|
|
||||||
expect(config.agent?.["test_mode"]).toEqual({
|
expect(config.agent?.["test_mode"]).toEqual({
|
||||||
model: "test/model",
|
model: "test/model",
|
||||||
temperature: 0.5,
|
temperature: 0.5,
|
||||||
@@ -798,9 +668,8 @@ test("migrates mode field to agent field", async () => {
|
|||||||
options: {},
|
options: {},
|
||||||
permission: {},
|
permission: {},
|
||||||
})
|
})
|
||||||
},
|
}),
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("loads config from .opencode directory", async () => {
|
test("loads config from .opencode directory", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
@@ -1002,30 +871,26 @@ Nested command template`,
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("updates config and writes to file", async () => {
|
it.instance("updates config and writes to file", () =>
|
||||||
await using tmp = await tmpdir()
|
Effect.gen(function* () {
|
||||||
await withTestInstance({
|
const test = yield* TestInstance
|
||||||
directory: tmp.path,
|
yield* Config.Service.use((svc) =>
|
||||||
fn: async (ctx) => {
|
svc.update(ConfigParse.schema(Config.Info, { model: "updated/model" }, "test:config")),
|
||||||
const newConfig = { model: "updated/model" }
|
)
|
||||||
await save(newConfig as any, ctx)
|
|
||||||
|
|
||||||
const writtenConfig = await Filesystem.readJson<{ model: string }>(path.join(tmp.path, "config.json"))
|
const writtenConfig = yield* Effect.promise(() =>
|
||||||
|
Filesystem.readJson<{ model: string }>(path.join(test.directory, "config.json")),
|
||||||
|
)
|
||||||
expect(writtenConfig.model).toBe("updated/model")
|
expect(writtenConfig.model).toBe("updated/model")
|
||||||
},
|
}),
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("gets config directories", async () => {
|
it.instance("gets config directories", () =>
|
||||||
await using tmp = await tmpdir()
|
Effect.gen(function* () {
|
||||||
await withTestInstance({
|
const dirs = yield* Config.Service.use((svc) => svc.directories())
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
const dirs = await listDirs(ctx)
|
|
||||||
expect(dirs.length).toBeGreaterThanOrEqual(1)
|
expect(dirs.length).toBeGreaterThanOrEqual(1)
|
||||||
},
|
}),
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", async () => {
|
test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", async () => {
|
||||||
if (process.platform === "win32") return
|
if (process.platform === "win32") return
|
||||||
@@ -1101,9 +966,6 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// TODO: this is a hack to wait for backgruounded gitignore
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
||||||
|
|
||||||
expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true)
|
expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true)
|
||||||
expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json")
|
expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json")
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -173,8 +173,12 @@ const startWorkspaceSyncingWithFlag = (projectID: ProjectID, experimentalWorkspa
|
|||||||
Effect.provide(workspaceLayer(experimentalWorkspaces)),
|
Effect.provide(workspaceLayer(experimentalWorkspaces)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const waitForWorkspaceSync = (workspaceID: WorkspaceID, state: Record<string, number>, signal?: AbortSignal) =>
|
const waitForWorkspaceSync = (
|
||||||
runWorkspace(Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal)))
|
workspaceID: WorkspaceID,
|
||||||
|
state: Record<string, number>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
timeout?: number,
|
||||||
|
) => runWorkspace(Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal, timeout)))
|
||||||
|
|
||||||
function captureGlobalEvents() {
|
function captureGlobalEvents() {
|
||||||
const events: GlobalEvent[] = []
|
const events: GlobalEvent[] = []
|
||||||
@@ -1639,9 +1643,9 @@ describe("workspace waitForSync", () => {
|
|||||||
await withInstance(async () => {
|
await withInstance(async () => {
|
||||||
const sessionID = SessionID.descending("ses_wait_timeout")
|
const sessionID = SessionID.descending("ses_wait_timeout")
|
||||||
|
|
||||||
await expect(waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_timeout"), { [sessionID]: 1 })).rejects.toThrow(
|
await expect(
|
||||||
`Timed out waiting for sync fence: {"${sessionID}":1}`,
|
waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_timeout"), { [sessionID]: 1 }, undefined, 25),
|
||||||
)
|
).rejects.toThrow(`Timed out waiting for sync fence: {"${sessionID}":1}`)
|
||||||
})
|
})
|
||||||
}, 7000)
|
}, 7000)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ describe("RuntimeFlags", () => {
|
|||||||
expect(flags.experimentalEventSystem).toBe(true)
|
expect(flags.experimentalEventSystem).toBe(true)
|
||||||
expect(flags.experimentalWorkspaces).toBe(true)
|
expect(flags.experimentalWorkspaces).toBe(true)
|
||||||
expect(flags.experimentalIconDiscovery).toBe(true)
|
expect(flags.experimentalIconDiscovery).toBe(true)
|
||||||
|
expect(flags.experimentalNativeLlm).toBe(true)
|
||||||
expect(flags.client).toBe("desktop")
|
expect(flags.client).toBe("desktop")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -80,6 +81,16 @@ describe("RuntimeFlags", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("enables native LLM via dedicated or umbrella flag", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const explicit = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL_NATIVE_LLM: "true" })))
|
||||||
|
const umbrella = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL: "true" })))
|
||||||
|
|
||||||
|
expect(explicit.experimentalNativeLlm).toBe(true)
|
||||||
|
expect(umbrella.experimentalNativeLlm).toBe(true)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("layer accepts partial test overrides and fills defaults from Config definitions", () =>
|
it.effect("layer accepts partial test overrides and fills defaults from Config definitions", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const flags = yield* readFlags.pipe(
|
const flags = yield* readFlags.pipe(
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { realpath } from "fs/promises"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { ConfigProvider, Deferred, Effect, Layer, Option } from "effect"
|
import { ConfigProvider, Deferred, Duration, Effect, Layer, Option } from "effect"
|
||||||
import { TestInstance, provideInstance } from "../fixture/fixture"
|
import { TestInstance, provideInstance } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
|
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
|
||||||
@@ -78,23 +79,49 @@ function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextUpdate<E>(directory: string, check: (evt: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
|
function maybeNextUpdate<E>(
|
||||||
|
directory: string,
|
||||||
|
check: (evt: WatcherEvent) => boolean,
|
||||||
|
trigger: Effect.Effect<void, E>,
|
||||||
|
timeout: Duration.Input = "5 seconds",
|
||||||
|
) {
|
||||||
return Effect.acquireUseRelease(
|
return Effect.acquireUseRelease(
|
||||||
wait(directory, check),
|
wait(directory, check),
|
||||||
({ deferred }) =>
|
({ deferred }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* trigger
|
yield* trigger
|
||||||
return yield* Deferred.await(deferred).pipe(
|
return yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeout))
|
||||||
Effect.timeoutOrElse({
|
|
||||||
duration: "5 seconds",
|
|
||||||
orElse: () => Effect.fail(new Error("timed out waiting for file watcher update")),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
({ cleanup }) => Effect.sync(cleanup),
|
({ cleanup }) => Effect.sync(cleanup),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nextUpdate<E>(directory: string, check: (evt: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const result = yield* maybeNextUpdate(directory, check, trigger)
|
||||||
|
if (Option.isSome(result)) return result.value
|
||||||
|
return yield* Effect.fail(new Error("timed out waiting for file watcher update"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventuallyUpdate<E>(
|
||||||
|
directory: string,
|
||||||
|
check: (evt: WatcherEvent) => boolean,
|
||||||
|
trigger: () => Effect.Effect<void, E>,
|
||||||
|
) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
while (true) {
|
||||||
|
const result = yield* maybeNextUpdate(directory, check, trigger(), "250 millis")
|
||||||
|
if (Option.isSome(result)) return result.value
|
||||||
|
}
|
||||||
|
}).pipe(
|
||||||
|
Effect.timeoutOrElse({
|
||||||
|
duration: "5 seconds",
|
||||||
|
orElse: () => Effect.fail(new Error("timed out waiting for file watcher readiness")),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Effect that asserts no matching event arrives within `ms`. */
|
/** Effect that asserts no matching event arrives within `ms`. */
|
||||||
function noUpdate<E>(
|
function noUpdate<E>(
|
||||||
directory: string,
|
directory: string,
|
||||||
@@ -125,22 +152,25 @@ function ready(directory: string) {
|
|||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* AppFileSystem.Service
|
||||||
const git = yield* Git.Service
|
const git = yield* Git.Service
|
||||||
|
|
||||||
yield* nextUpdate(
|
yield* eventuallyUpdate(
|
||||||
directory,
|
directory,
|
||||||
(evt) => evt.file === file && evt.event === "add",
|
(evt) => evt.file === file,
|
||||||
fs.writeFileString(file, "ready"),
|
() => fs.writeFileString(file, `ready-${Math.random()}`),
|
||||||
).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid)
|
).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid)
|
||||||
|
|
||||||
if (!(yield* fs.existsSafe(head))) return
|
if (!(yield* fs.existsSafe(head))) return
|
||||||
|
|
||||||
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
const realHead = yield* Effect.promise(() => realpath(head).catch(() => head))
|
||||||
const hash = (yield* git.run(["rev-parse", "HEAD"], { cwd: directory })).text()
|
const hash = (yield* git.run(["rev-parse", "HEAD"], { cwd: directory })).text()
|
||||||
yield* nextUpdate(
|
yield* eventuallyUpdate(
|
||||||
directory,
|
directory,
|
||||||
(evt) => evt.file === head && evt.event !== "unlink",
|
(evt) => (evt.file === head || evt.file === realHead) && evt.event !== "unlink",
|
||||||
fs
|
() => {
|
||||||
|
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
||||||
|
return fs
|
||||||
.writeFileString(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n")
|
.writeFileString(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n")
|
||||||
.pipe(Effect.andThen(fs.writeFileString(head, `ref: refs/heads/${branch}\n`))),
|
.pipe(Effect.andThen(fs.writeFileString(head, `ref: refs/heads/${branch}\n`)))
|
||||||
|
},
|
||||||
).pipe(Effect.asVoid)
|
).pipe(Effect.asVoid)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { mkdir } from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
export async function markPluginDependenciesReady(dir: string) {
|
||||||
|
await mkdir(path.join(dir, "node_modules"), { recursive: true })
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "package-lock.json"),
|
||||||
|
JSON.stringify({ packages: { "": { dependencies: { "@opencode-ai/plugin": "0.0.0" } } } }),
|
||||||
|
)
|
||||||
|
}
|
||||||
+31
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,186 @@
|
|||||||
|
// Subprocess test harness for the opencode CLI. Spawns the real binary against
|
||||||
|
// a TestLLMServer running in-process at a random port, with full env isolation.
|
||||||
|
//
|
||||||
|
// This is the missing test tier: in-process tests can't catch bugs that span
|
||||||
|
// argv parsing → server boot → SDK call → event consumption → exit code (like
|
||||||
|
// the original /event race or #27371's invalid-model hang).
|
||||||
|
//
|
||||||
|
// Configuration flows through opencode's built-in test affordances:
|
||||||
|
// - OPENCODE_CONFIG_CONTENT : provider config inline, no files to find
|
||||||
|
// - OPENCODE_TEST_HOME : pins os.homedir() → tmpdir
|
||||||
|
// - OPENCODE_DISABLE_PROJECT_CONFIG : skip walking up for opencode.json
|
||||||
|
// - OPENCODE_PURE : skip external plugin discovery + install
|
||||||
|
// - OPENCODE_DISABLE_AUTOUPDATE / AUTOCOMPACT / MODELS_FETCH : no background work
|
||||||
|
// Plus HOME / XDG_* pointing at the tmpdir for belt-and-suspenders isolation.
|
||||||
|
//
|
||||||
|
// Today only `opencode.run` is fully wired. The shape supports adding more
|
||||||
|
// builders (`opencode.serve(opts)`, `opencode.acp(opts)`, `opencode.auth(...)`)
|
||||||
|
// without changing the fixture. Long-lived commands like `serve` will need a
|
||||||
|
// different return shape — see the TODO at the bottom of OpencodeCli.
|
||||||
|
import type { TestOptions } from "bun:test"
|
||||||
|
import * as Scope from "effect/Scope"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import path from "node:path"
|
||||||
|
import fs from "node:fs/promises"
|
||||||
|
import os from "node:os"
|
||||||
|
import { Process } from "@/util/process"
|
||||||
|
import { TestLLMServer } from "./llm-server"
|
||||||
|
import { testProviderConfig } from "./test-provider"
|
||||||
|
import { it } from "./effect"
|
||||||
|
|
||||||
|
const opencodeRoot = path.resolve(import.meta.dir, "../../")
|
||||||
|
const cliEntry = path.join(opencodeRoot, "src/index.ts")
|
||||||
|
|
||||||
|
export const testModelID = "test/test-model"
|
||||||
|
|
||||||
|
function isolatedEnv(home: string, configJson: string): Record<string, string> {
|
||||||
|
return {
|
||||||
|
OPENCODE_TEST_HOME: home,
|
||||||
|
HOME: home,
|
||||||
|
XDG_CONFIG_HOME: path.join(home, ".config"),
|
||||||
|
XDG_DATA_HOME: path.join(home, ".local/share"),
|
||||||
|
XDG_STATE_HOME: path.join(home, ".local/state"),
|
||||||
|
XDG_CACHE_HOME: path.join(home, ".cache"),
|
||||||
|
OPENCODE_CONFIG_CONTENT: configJson,
|
||||||
|
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
|
||||||
|
OPENCODE_PURE: "1",
|
||||||
|
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
||||||
|
OPENCODE_DISABLE_AUTOCOMPACT: "1",
|
||||||
|
OPENCODE_DISABLE_MODELS_FETCH: "1",
|
||||||
|
OPENCODE_AUTH_CONTENT: "{}",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RunResult = {
|
||||||
|
readonly exitCode: number
|
||||||
|
readonly stdout: string
|
||||||
|
readonly stderr: string
|
||||||
|
readonly durationMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpawnOpts = { readonly timeoutMs?: number; readonly env?: Record<string, string> }
|
||||||
|
|
||||||
|
// Typed equivalent of constructing argv for `opencode run`. New flags should
|
||||||
|
// land here so tests stay grep-able and refactor-safe.
|
||||||
|
export type RunOpts = SpawnOpts & {
|
||||||
|
readonly model?: string
|
||||||
|
readonly agent?: string
|
||||||
|
readonly format?: "default" | "json"
|
||||||
|
readonly command?: string
|
||||||
|
readonly printLogs?: boolean
|
||||||
|
readonly extraArgs?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OpencodeCli = {
|
||||||
|
// High-level: run a single prompt against the test model. Short-lived.
|
||||||
|
readonly run: (message: string, opts?: RunOpts) => Effect.Effect<RunResult>
|
||||||
|
// Escape hatch: any CLI invocation with full control over argv. Used to test
|
||||||
|
// commands that don't yet have a typed builder.
|
||||||
|
readonly spawn: (args: string[], opts?: SpawnOpts) => Effect.Effect<RunResult>
|
||||||
|
// Convenience assertion. Dumps captured stderr/stdout on mismatch so CI
|
||||||
|
// failures are debuggable without re-running locally.
|
||||||
|
readonly expectExit: (result: RunResult, expected: number, label?: string) => void
|
||||||
|
// Parse `--format json` stdout into one event object per non-empty line.
|
||||||
|
// The CLI writes `JSON.stringify({ type, sessionID, ... }) + EOL` for each
|
||||||
|
// event (see src/cli/cmd/run.ts `emit`). Throws on a malformed line so
|
||||||
|
// tests fail loudly rather than silently skipping data.
|
||||||
|
readonly parseJsonEvents: (stdout: string) => Array<Record<string, unknown>>
|
||||||
|
// TODO: long-lived builders for `serve` / `acp` / etc. need a different
|
||||||
|
// return shape — they yield a handle with .url / .kill and live inside the
|
||||||
|
// surrounding Scope. Add when the first long-lived command is tested.
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CliFixture = {
|
||||||
|
readonly llm: TestLLMServer["Service"]
|
||||||
|
readonly home: string
|
||||||
|
readonly opencode: OpencodeCli
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provisions a TestLLMServer + tmpdir + spawn helper and invokes fn. Cleans
|
||||||
|
// up the tmpdir on scope exit. TestLLMServer.layer is provided internally so
|
||||||
|
// the caller doesn't need to wire it up — the fixture's lifetime is tied to
|
||||||
|
// the surrounding Scope.
|
||||||
|
export function withCliFixture<A, E>(
|
||||||
|
fn: (input: CliFixture) => Effect.Effect<A, E>,
|
||||||
|
): Effect.Effect<A, E | unknown, Scope.Scope> {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const llm = yield* TestLLMServer
|
||||||
|
|
||||||
|
const home = path.join(os.tmpdir(), "oc-cli-" + Math.random().toString(36).slice(2))
|
||||||
|
yield* Effect.promise(() => fs.mkdir(home, { recursive: true }))
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.promise(() => fs.rm(home, { recursive: true, force: true }).catch(() => undefined)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const configJson = JSON.stringify(testProviderConfig(llm.url))
|
||||||
|
const env = isolatedEnv(home, configJson)
|
||||||
|
|
||||||
|
const spawn = (args: string[], opts?: SpawnOpts): Effect.Effect<RunResult> =>
|
||||||
|
Effect.promise(async () => {
|
||||||
|
const start = Date.now()
|
||||||
|
// Process.run pipes stdout/stderr by default and returns them as Buffers.
|
||||||
|
const result = await Process.run(["bun", "run", "--conditions=browser", cliEntry, ...args], {
|
||||||
|
cwd: home,
|
||||||
|
timeout: opts?.timeoutMs ?? 30_000,
|
||||||
|
env: { ...process.env, ...env, ...opts?.env },
|
||||||
|
nothrow: true,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
exitCode: result.code,
|
||||||
|
stdout: result.stdout.toString(),
|
||||||
|
stderr: result.stderr.toString(),
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const run = (message: string, opts?: RunOpts): Effect.Effect<RunResult> => {
|
||||||
|
const argv: string[] = ["run"]
|
||||||
|
if (opts?.printLogs) argv.push("--print-logs")
|
||||||
|
argv.push("--model", opts?.model ?? testModelID)
|
||||||
|
if (opts?.agent) argv.push("--agent", opts.agent)
|
||||||
|
if (opts?.format) argv.push("--format", opts.format)
|
||||||
|
if (opts?.command) argv.push("--command", opts.command)
|
||||||
|
if (opts?.extraArgs) argv.push(...opts.extraArgs)
|
||||||
|
argv.push(message)
|
||||||
|
return spawn(argv, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
const opencode: OpencodeCli = { run, spawn, expectExit, parseJsonEvents }
|
||||||
|
|
||||||
|
return yield* fn({ llm, home, opencode })
|
||||||
|
}).pipe(Effect.provide(TestLLMServer.layer))
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonEvents(stdout: string): Array<Record<string, unknown>> {
|
||||||
|
return stdout
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
.map((line) => JSON.parse(line) as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience for the common assertion pattern. Dumps stderr/stdout when
|
||||||
|
// the exit code doesn't match — saves debugging time on CI failures.
|
||||||
|
function expectExit(result: RunResult, expected: number, label = "opencode") {
|
||||||
|
if (result.exitCode === expected) return
|
||||||
|
const tail = (s: string, n: number) => (s.length > n ? "..." + s.slice(-n) : s)
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`[${label}] expected exit ${expected}, got ${result.exitCode} after ${result.durationMs}ms`)
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`[${label}] stderr (last 2000):\n${tail(result.stderr, 2000)}`)
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`[${label}] stdout (last 500):\n${tail(result.stdout, 500)}`)
|
||||||
|
throw new Error(`${label}: expected exit ${expected}, got ${result.exitCode}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `cliIt.live(name, fixture => effect)` is the same as
|
||||||
|
// `it.live(name, () => withCliFixture(fixture))` — one fewer nesting level at
|
||||||
|
// every call site. Use this for any test that needs the opencode CLI fixture.
|
||||||
|
//
|
||||||
|
// Only `.live` is exposed because subprocess tests must run against the real
|
||||||
|
// clock — a TestClock-paused environment can't drive a child process. If you
|
||||||
|
// need `.only` or `.skip`, fall back to `it.live` + `withCliFixture` directly.
|
||||||
|
export const cliIt = {
|
||||||
|
live: <A, E>(name: string, body: (input: CliFixture) => Effect.Effect<A, E>, opts?: number | TestOptions) =>
|
||||||
|
it.live(name, () => withCliFixture(body), opts),
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Shared provider config for tests that need opencode to talk to a fake LLM
|
||||||
|
// over a real HTTP endpoint. Registers a single provider `test` with a single
|
||||||
|
// model `test-model` (i.e. `--model test/test-model`), pointed at the URL the
|
||||||
|
// caller supplies (typically a TestLLMServer instance).
|
||||||
|
//
|
||||||
|
// Used by:
|
||||||
|
// - test/lib/run-process.ts (subprocess CLI tests)
|
||||||
|
// - test/server/httpapi-sdk.test.ts (in-process SDK tests)
|
||||||
|
export function testProviderConfig(llmUrl: string) {
|
||||||
|
return {
|
||||||
|
formatter: false,
|
||||||
|
lsp: false,
|
||||||
|
provider: {
|
||||||
|
test: {
|
||||||
|
name: "Test",
|
||||||
|
id: "test",
|
||||||
|
env: [],
|
||||||
|
npm: "@ai-sdk/openai-compatible",
|
||||||
|
models: {
|
||||||
|
"test-model": {
|
||||||
|
id: "test-model",
|
||||||
|
name: "Test Model",
|
||||||
|
attachment: false,
|
||||||
|
reasoning: false,
|
||||||
|
temperature: false,
|
||||||
|
tool_call: true,
|
||||||
|
release_date: "2025-01-01",
|
||||||
|
limit: { context: 100_000, output: 10_000 },
|
||||||
|
cost: { input: 0, output: 0 },
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
options: { apiKey: "test-key", baseURL: llmUrl },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,7 +66,7 @@ describe("plugin.install.concurrent", () => {
|
|||||||
test("serializes concurrent server config updates across processes", async () => {
|
test("serializes concurrent server config updates across processes", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
const target = await plugin(tmp.path, ["server"])
|
const target = await plugin(tmp.path, ["server"])
|
||||||
const all = mods("mod-server", 12)
|
const all = mods("mod-server", 6)
|
||||||
|
|
||||||
const out = await Promise.all(
|
const out = await Promise.all(
|
||||||
all.map((mod) =>
|
all.map((mod) =>
|
||||||
@@ -89,7 +89,7 @@ describe("plugin.install.concurrent", () => {
|
|||||||
test("serializes concurrent server+tui config updates across processes", async () => {
|
test("serializes concurrent server+tui config updates across processes", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
const target = await plugin(tmp.path, ["server", "tui"])
|
const target = await plugin(tmp.path, ["server", "tui"])
|
||||||
const all = mods("mod-both", 10)
|
const all = mods("mod-both", 6)
|
||||||
|
|
||||||
const out = await Promise.all(
|
const out = await Promise.all(
|
||||||
all.map((mod) =>
|
all.map((mod) =>
|
||||||
@@ -118,7 +118,7 @@ describe("plugin.install.concurrent", () => {
|
|||||||
await fs.mkdir(path.dirname(cfg), { recursive: true })
|
await fs.mkdir(path.dirname(cfg), { recursive: true })
|
||||||
await Bun.write(cfg, JSON.stringify({ plugin: ["seed@1.0.0"] }, null, 2))
|
await Bun.write(cfg, JSON.stringify({ plugin: ["seed@1.0.0"] }, null, 2))
|
||||||
|
|
||||||
const next = mods("mod-json", 8)
|
const next = mods("mod-json", 5)
|
||||||
const out = await Promise.all(
|
const out = await Promise.all(
|
||||||
next.map((mod) =>
|
next.map((mod) =>
|
||||||
run({
|
run({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { mkdir, unlink } from "fs/promises"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
||||||
import { disposeAllInstances, tmpdir, withTestInstance } from "../fixture/fixture"
|
import { disposeAllInstances, tmpdir, withTestInstance } from "../fixture/fixture"
|
||||||
|
import { markPluginDependenciesReady } from "../fixture/plugin"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import type { InstanceContext } from "../../src/project/instance-context"
|
import type { InstanceContext } from "../../src/project/instance-context"
|
||||||
import { Plugin } from "../../src/plugin/index"
|
import { Plugin } from "../../src/plugin/index"
|
||||||
@@ -28,15 +29,21 @@ function rememberEnv(k: string) {
|
|||||||
if (!originalEnv.has(k)) originalEnv.set(k, process.env[k])
|
if (!originalEnv.has(k)) originalEnv.set(k, process.env[k])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setProcessEnv = (k: string, v: string) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
rememberEnv(k)
|
||||||
|
process.env[k] = v
|
||||||
|
})
|
||||||
|
|
||||||
const set = (ctx: InstanceContext, k: string, v: string) => {
|
const set = (ctx: InstanceContext, k: string, v: string) => {
|
||||||
rememberEnv(k)
|
rememberEnv(k)
|
||||||
process.env[k] = v
|
process.env[k] = v
|
||||||
return env.runSync((svc) => svc.set(k, v).pipe(Effect.provideService(InstanceRef, ctx)))
|
return env.runPromise((svc) => svc.set(k, v).pipe(Effect.provideService(InstanceRef, ctx)))
|
||||||
}
|
}
|
||||||
const remove = (ctx: InstanceContext, k: string) => {
|
const remove = (ctx: InstanceContext, k: string) => {
|
||||||
rememberEnv(k)
|
rememberEnv(k)
|
||||||
delete process.env[k]
|
delete process.env[k]
|
||||||
return env.runSync((svc) => svc.remove(k).pipe(Effect.provideService(InstanceRef, ctx)))
|
return env.runPromise((svc) => svc.remove(k).pipe(Effect.provideService(InstanceRef, ctx)))
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -80,10 +87,6 @@ async function getModel(providerID: ProviderID, modelID: ModelID, ctx: InstanceC
|
|||||||
return run(ctx, (provider) => provider.getModel(providerID, modelID))
|
return run(ctx, (provider) => provider.getModel(providerID, modelID))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getLanguage(model: Provider.Model, ctx: InstanceContext) {
|
|
||||||
return run(ctx, (provider) => provider.getLanguage(model))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function closest(providerID: ProviderID, query: string[], ctx: InstanceContext) {
|
async function closest(providerID: ProviderID, query: string[], ctx: InstanceContext) {
|
||||||
return run(ctx, (provider) => provider.closest(providerID, query))
|
return run(ctx, (provider) => provider.closest(providerID, query))
|
||||||
}
|
}
|
||||||
@@ -92,18 +95,6 @@ async function getSmallModel(providerID: ProviderID, ctx: InstanceContext) {
|
|||||||
return run(ctx, (provider) => provider.getSmallModel(providerID))
|
return run(ctx, (provider) => provider.getSmallModel(providerID))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function defaultModel(ctx: InstanceContext) {
|
|
||||||
return run(ctx, (provider) => provider.defaultModel())
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markPluginDependenciesReady(dir: string) {
|
|
||||||
await mkdir(path.join(dir, "node_modules"), { recursive: true })
|
|
||||||
await Bun.write(
|
|
||||||
path.join(dir, "package-lock.json"),
|
|
||||||
JSON.stringify({ packages: { "": { dependencies: { "@opencode-ai/plugin": "0.0.0" } } } }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function paid(providers: Awaited<ReturnType<typeof list>>) {
|
function paid(providers: Awaited<ReturnType<typeof list>>) {
|
||||||
const item = providers[ProviderID.make("opencode")]
|
const item = providers[ProviderID.make("opencode")]
|
||||||
expect(item).toBeDefined()
|
expect(item).toBeDefined()
|
||||||
@@ -135,38 +126,26 @@ const alphaProviderConfig = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
test("provider loaded from env variable", async () => {
|
it.instance("provider loaded from env variable", () =>
|
||||||
await using tmp = await tmpdir({
|
Effect.gen(function* () {
|
||||||
init: async (dir) => {
|
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
await Bun.write(
|
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||||
path.join(dir, "opencode.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
|
||||||
const providers = await list(ctx)
|
|
||||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||||
// Provider should retain its connection source even if custom loaders
|
// Provider should retain its connection source even if custom loaders
|
||||||
// merge additional options.
|
// merge additional options.
|
||||||
expect(providers[ProviderID.anthropic].source).toBe("env")
|
expect(providers[ProviderID.anthropic].source).toBe("env")
|
||||||
expect(providers[ProviderID.anthropic].options.headers["anthropic-beta"]).toBeDefined()
|
expect(providers[ProviderID.anthropic].options.headers["anthropic-beta"]).toBeDefined()
|
||||||
},
|
}),
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("provider loaded from config with apiKey option", async () => {
|
it.instance(
|
||||||
await using tmp = await tmpdir({
|
"provider loaded from config with apiKey option",
|
||||||
init: async (dir) => {
|
Effect.gen(function* () {
|
||||||
await Bun.write(
|
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||||
path.join(dir, "opencode.json"),
|
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||||
JSON.stringify({
|
}),
|
||||||
$schema: "https://opencode.ai/config.json",
|
{
|
||||||
|
config: {
|
||||||
provider: {
|
provider: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
options: {
|
options: {
|
||||||
@@ -174,129 +153,84 @@ test("provider loaded from config with apiKey option", async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
const providers = await list(ctx)
|
|
||||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
|
||||||
},
|
},
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("disabled_providers excludes provider", async () => {
|
it.instance(
|
||||||
await using tmp = await tmpdir({
|
"disabled_providers excludes provider",
|
||||||
init: async (dir) => {
|
Effect.gen(function* () {
|
||||||
await Bun.write(
|
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
path.join(dir, "opencode.json"),
|
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
disabled_providers: ["anthropic"],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
|
||||||
const providers = await list(ctx)
|
|
||||||
expect(providers[ProviderID.anthropic]).toBeUndefined()
|
expect(providers[ProviderID.anthropic]).toBeUndefined()
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("enabled_providers restricts to only listed providers", async () => {
|
|
||||||
await using tmp = await tmpdir({
|
|
||||||
init: async (dir) => {
|
|
||||||
await Bun.write(
|
|
||||||
path.join(dir, "opencode.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
enabled_providers: ["anthropic"],
|
|
||||||
}),
|
}),
|
||||||
)
|
{ config: { disabled_providers: ["anthropic"] } },
|
||||||
},
|
)
|
||||||
})
|
|
||||||
await withTestInstance({
|
it.instance(
|
||||||
directory: tmp.path,
|
"enabled_providers restricts to only listed providers",
|
||||||
fn: async (ctx) => {
|
Effect.gen(function* () {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
set(ctx, "OPENAI_API_KEY", "test-openai-key")
|
yield* setProcessEnv("OPENAI_API_KEY", "test-openai-key")
|
||||||
const providers = await list(ctx)
|
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||||
expect(providers[ProviderID.openai]).toBeUndefined()
|
expect(providers[ProviderID.openai]).toBeUndefined()
|
||||||
},
|
}),
|
||||||
})
|
{ config: { enabled_providers: ["anthropic"] } },
|
||||||
})
|
)
|
||||||
|
|
||||||
test("model whitelist filters models for provider", async () => {
|
it.instance(
|
||||||
await using tmp = await tmpdir({
|
"model whitelist filters models for provider",
|
||||||
init: async (dir) => {
|
Effect.gen(function* () {
|
||||||
await Bun.write(
|
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
path.join(dir, "opencode.json"),
|
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||||
JSON.stringify({
|
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||||
$schema: "https://opencode.ai/config.json",
|
const models = Object.keys(providers[ProviderID.anthropic].models)
|
||||||
|
expect(models).toContain("claude-sonnet-4-20250514")
|
||||||
|
expect(models.length).toBe(1)
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
config: {
|
||||||
provider: {
|
provider: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
whitelist: ["claude-sonnet-4-20250514"],
|
whitelist: ["claude-sonnet-4-20250514"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
await withTestInstance({
|
)
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
it.instance(
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
"model blacklist excludes specific models",
|
||||||
const providers = await list(ctx)
|
Effect.gen(function* () {
|
||||||
|
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
|
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||||
const models = Object.keys(providers[ProviderID.anthropic].models)
|
const models = Object.keys(providers[ProviderID.anthropic].models)
|
||||||
expect(models).toContain("claude-sonnet-4-20250514")
|
expect(models).not.toContain("claude-sonnet-4-20250514")
|
||||||
expect(models.length).toBe(1)
|
}),
|
||||||
},
|
{
|
||||||
})
|
config: {
|
||||||
})
|
|
||||||
|
|
||||||
test("model blacklist excludes specific models", async () => {
|
|
||||||
await using tmp = await tmpdir({
|
|
||||||
init: async (dir) => {
|
|
||||||
await Bun.write(
|
|
||||||
path.join(dir, "opencode.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
provider: {
|
provider: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
blacklist: ["claude-sonnet-4-20250514"],
|
blacklist: ["claude-sonnet-4-20250514"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
|
||||||
const providers = await list(ctx)
|
|
||||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
|
||||||
const models = Object.keys(providers[ProviderID.anthropic].models)
|
|
||||||
expect(models).not.toContain("claude-sonnet-4-20250514")
|
|
||||||
},
|
},
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("custom model alias via config", async () => {
|
it.instance(
|
||||||
await using tmp = await tmpdir({
|
"custom model alias via config",
|
||||||
init: async (dir) => {
|
Effect.gen(function* () {
|
||||||
await Bun.write(
|
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
path.join(dir, "opencode.json"),
|
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||||
JSON.stringify({
|
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||||
$schema: "https://opencode.ai/config.json",
|
expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined()
|
||||||
|
expect(providers[ProviderID.anthropic].models["my-alias"].name).toBe("My Custom Alias")
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
config: {
|
||||||
provider: {
|
provider: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
models: {
|
models: {
|
||||||
@@ -307,29 +241,20 @@ test("custom model alias via config", async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
|
||||||
const providers = await list(ctx)
|
|
||||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
|
||||||
expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined()
|
|
||||||
expect(providers[ProviderID.anthropic].models["my-alias"].name).toBe("My Custom Alias")
|
|
||||||
},
|
},
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("custom provider with npm package", async () => {
|
it.instance(
|
||||||
await using tmp = await tmpdir({
|
"custom provider with npm package",
|
||||||
init: async (dir) => {
|
Effect.gen(function* () {
|
||||||
await Bun.write(
|
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||||
path.join(dir, "opencode.json"),
|
expect(providers[ProviderID.make("custom-provider")]).toBeDefined()
|
||||||
JSON.stringify({
|
expect(providers[ProviderID.make("custom-provider")].name).toBe("Custom Provider")
|
||||||
$schema: "https://opencode.ai/config.json",
|
expect(providers[ProviderID.make("custom-provider")].models["custom-model"]).toBeDefined()
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
config: {
|
||||||
provider: {
|
provider: {
|
||||||
"custom-provider": {
|
"custom-provider": {
|
||||||
name: "Custom Provider",
|
name: "Custom Provider",
|
||||||
@@ -351,20 +276,9 @@ test("custom provider with npm package", async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
const providers = await list(ctx)
|
|
||||||
expect(providers[ProviderID.make("custom-provider")]).toBeDefined()
|
|
||||||
expect(providers[ProviderID.make("custom-provider")].name).toBe("Custom Provider")
|
|
||||||
expect(providers[ProviderID.make("custom-provider")].models["custom-model"]).toBeDefined()
|
|
||||||
},
|
},
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
it.instance(
|
it.instance(
|
||||||
"filters alpha provider models by default",
|
"filters alpha provider models by default",
|
||||||
@@ -386,13 +300,20 @@ experimentalModels.instance(
|
|||||||
{ config: alphaProviderConfig },
|
{ config: alphaProviderConfig },
|
||||||
)
|
)
|
||||||
|
|
||||||
test("custom DeepSeek openai-compatible model defaults interleaved reasoning field", async () => {
|
it.instance(
|
||||||
await using tmp = await tmpdir({
|
"custom DeepSeek openai-compatible model defaults interleaved reasoning field",
|
||||||
init: async (dir) => {
|
Effect.gen(function* () {
|
||||||
await Bun.write(
|
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||||
path.join(dir, "opencode.json"),
|
const provider = providers[ProviderID.make("custom-provider")]
|
||||||
JSON.stringify({
|
expect(provider.models["deepseek-r1"].capabilities.interleaved).toEqual({ field: "reasoning_content" })
|
||||||
$schema: "https://opencode.ai/config.json",
|
expect(provider.models["deepseek-details"].capabilities.interleaved).toEqual({ field: "reasoning_details" })
|
||||||
|
expect(provider.models["custom-model"].capabilities.interleaved).toBe(false)
|
||||||
|
expect(providers[ProviderID.make("custom-anthropic-provider")].models["deepseek-r1"].capabilities.interleaved).toBe(
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
config: {
|
||||||
provider: {
|
provider: {
|
||||||
"custom-provider": {
|
"custom-provider": {
|
||||||
name: "Custom Provider",
|
name: "Custom Provider",
|
||||||
@@ -428,32 +349,22 @@ test("custom DeepSeek openai-compatible model defaults interleaved reasoning fie
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
const providers = await list(ctx)
|
|
||||||
const provider = providers[ProviderID.make("custom-provider")]
|
|
||||||
expect(provider.models["deepseek-r1"].capabilities.interleaved).toEqual({ field: "reasoning_content" })
|
|
||||||
expect(provider.models["deepseek-details"].capabilities.interleaved).toEqual({ field: "reasoning_details" })
|
|
||||||
expect(provider.models["custom-model"].capabilities.interleaved).toBe(false)
|
|
||||||
expect(
|
|
||||||
providers[ProviderID.make("custom-anthropic-provider")].models["deepseek-r1"].capabilities.interleaved,
|
|
||||||
).toBe(false)
|
|
||||||
},
|
},
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("env variable takes precedence, config merges options", async () => {
|
it.instance(
|
||||||
await using tmp = await tmpdir({
|
"env variable takes precedence, config merges options",
|
||||||
init: async (dir) => {
|
Effect.gen(function* () {
|
||||||
await Bun.write(
|
yield* setProcessEnv("ANTHROPIC_API_KEY", "env-api-key")
|
||||||
path.join(dir, "opencode.json"),
|
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||||
JSON.stringify({
|
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||||
$schema: "https://opencode.ai/config.json",
|
// Config options should be merged
|
||||||
|
expect(providers[ProviderID.anthropic].options.timeout).toBe(60000)
|
||||||
|
expect(providers[ProviderID.anthropic].options.chunkTimeout).toBe(15000)
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
config: {
|
||||||
provider: {
|
provider: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
options: {
|
options: {
|
||||||
@@ -462,47 +373,22 @@ test("env variable takes precedence, config merges options", async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "env-api-key")
|
|
||||||
const providers = await list(ctx)
|
|
||||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
|
||||||
// Config options should be merged
|
|
||||||
expect(providers[ProviderID.anthropic].options.timeout).toBe(60000)
|
|
||||||
expect(providers[ProviderID.anthropic].options.chunkTimeout).toBe(15000)
|
|
||||||
},
|
},
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("getModel returns model for valid provider/model", async () => {
|
it.instance("getModel returns model for valid provider/model", () =>
|
||||||
await using tmp = await tmpdir({
|
Effect.gen(function* () {
|
||||||
init: async (dir) => {
|
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
await Bun.write(
|
const provider = yield* Provider.Service
|
||||||
path.join(dir, "opencode.json"),
|
const model = yield* provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
|
||||||
const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"), ctx)
|
|
||||||
expect(model).toBeDefined()
|
expect(model).toBeDefined()
|
||||||
expect(String(model.providerID)).toBe("anthropic")
|
expect(String(model.providerID)).toBe("anthropic")
|
||||||
expect(String(model.id)).toBe("claude-sonnet-4-20250514")
|
expect(String(model.id)).toBe("claude-sonnet-4-20250514")
|
||||||
const language = await getLanguage(model, ctx)
|
const language = yield* provider.getLanguage(model)
|
||||||
expect(language).toBeDefined()
|
expect(language).toBeDefined()
|
||||||
},
|
}),
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("getModel throws ModelNotFoundError for invalid model", async () => {
|
test("getModel throws ModelNotFoundError for invalid model", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
@@ -518,7 +404,7 @@ test("getModel throws ModelNotFoundError for invalid model", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"), ctx)).rejects.toThrow()
|
expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"), ctx)).rejects.toThrow()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -555,50 +441,25 @@ test("parseModel handles model IDs with slashes", () => {
|
|||||||
expect(String(result.modelID)).toBe("anthropic/claude-3-opus")
|
expect(String(result.modelID)).toBe("anthropic/claude-3-opus")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("defaultModel returns first available model when no config set", async () => {
|
it.instance("defaultModel returns first available model when no config set", () =>
|
||||||
await using tmp = await tmpdir({
|
Effect.gen(function* () {
|
||||||
init: async (dir) => {
|
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
await Bun.write(
|
const model = yield* Provider.Service.use((provider) => provider.defaultModel())
|
||||||
path.join(dir, "opencode.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
|
||||||
const model = await defaultModel(ctx)
|
|
||||||
expect(model.providerID).toBeDefined()
|
expect(model.providerID).toBeDefined()
|
||||||
expect(model.modelID).toBeDefined()
|
expect(model.modelID).toBeDefined()
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("defaultModel respects config model setting", async () => {
|
|
||||||
await using tmp = await tmpdir({
|
|
||||||
init: async (dir) => {
|
|
||||||
await Bun.write(
|
|
||||||
path.join(dir, "opencode.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
model: "anthropic/claude-sonnet-4-20250514",
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
|
||||||
})
|
it.instance(
|
||||||
await withTestInstance({
|
"defaultModel respects config model setting",
|
||||||
directory: tmp.path,
|
Effect.gen(function* () {
|
||||||
fn: async (ctx) => {
|
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
const model = yield* Provider.Service.use((provider) => provider.defaultModel())
|
||||||
const model = await defaultModel(ctx)
|
|
||||||
expect(String(model.providerID)).toBe("anthropic")
|
expect(String(model.providerID)).toBe("anthropic")
|
||||||
expect(String(model.modelID)).toBe("claude-sonnet-4-20250514")
|
expect(String(model.modelID)).toBe("claude-sonnet-4-20250514")
|
||||||
},
|
}),
|
||||||
})
|
{ config: { model: "anthropic/claude-sonnet-4-20250514" } },
|
||||||
})
|
)
|
||||||
|
|
||||||
it.instance(
|
it.instance(
|
||||||
"provider with baseURL from config",
|
"provider with baseURL from config",
|
||||||
@@ -725,7 +586,7 @@ test("closest finds model by partial match", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const result = await closest(ProviderID.anthropic, ["sonnet-4"], ctx)
|
const result = await closest(ProviderID.anthropic, ["sonnet-4"], ctx)
|
||||||
expect(result).toBeDefined()
|
expect(result).toBeDefined()
|
||||||
expect(String(result?.providerID)).toBe("anthropic")
|
expect(String(result?.providerID)).toBe("anthropic")
|
||||||
@@ -778,7 +639,7 @@ test("getModel uses realIdByKey for aliased models", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined()
|
expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined()
|
||||||
|
|
||||||
@@ -891,7 +752,7 @@ test("model inherits properties from existing database model", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||||
expect(model.name).toBe("Custom Name for Sonnet")
|
expect(model.name).toBe("Custom Name for Sonnet")
|
||||||
@@ -917,7 +778,7 @@ test("disabled_providers prevents loading even with env var", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "OPENAI_API_KEY", "test-openai-key")
|
await set(ctx, "OPENAI_API_KEY", "test-openai-key")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
expect(providers[ProviderID.openai]).toBeUndefined()
|
expect(providers[ProviderID.openai]).toBeUndefined()
|
||||||
},
|
},
|
||||||
@@ -939,8 +800,8 @@ test("enabled_providers with empty array allows no providers", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
set(ctx, "OPENAI_API_KEY", "test-openai-key")
|
await set(ctx, "OPENAI_API_KEY", "test-openai-key")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
expect(Object.keys(providers).length).toBe(0)
|
expect(Object.keys(providers).length).toBe(0)
|
||||||
},
|
},
|
||||||
@@ -967,7 +828,7 @@ test("whitelist and blacklist can be combined", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||||
const models = Object.keys(providers[ProviderID.anthropic].models)
|
const models = Object.keys(providers[ProviderID.anthropic].models)
|
||||||
@@ -1074,7 +935,7 @@ test("getSmallModel returns appropriate small model", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const model = await getSmallModel(ProviderID.anthropic, ctx)
|
const model = await getSmallModel(ProviderID.anthropic, ctx)
|
||||||
expect(model).toBeDefined()
|
expect(model).toBeDefined()
|
||||||
expect(model?.id).toContain("haiku")
|
expect(model?.id).toContain("haiku")
|
||||||
@@ -1097,7 +958,7 @@ test("getSmallModel respects config small_model override", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const model = await getSmallModel(ProviderID.anthropic, ctx)
|
const model = await getSmallModel(ProviderID.anthropic, ctx)
|
||||||
expect(model).toBeDefined()
|
expect(model).toBeDefined()
|
||||||
expect(String(model?.providerID)).toBe("anthropic")
|
expect(String(model?.providerID)).toBe("anthropic")
|
||||||
@@ -1121,7 +982,7 @@ test("getSmallModel ignores invalid config small_model", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
expect(await getSmallModel(ProviderID.anthropic, ctx)).toBeUndefined()
|
expect(await getSmallModel(ProviderID.anthropic, ctx)).toBeUndefined()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -1164,8 +1025,8 @@ test("multiple providers can be configured simultaneously", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-anthropic-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-anthropic-key")
|
||||||
set(ctx, "OPENAI_API_KEY", "test-openai-key")
|
await set(ctx, "OPENAI_API_KEY", "test-openai-key")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||||
expect(providers[ProviderID.openai]).toBeDefined()
|
expect(providers[ProviderID.openai]).toBeDefined()
|
||||||
@@ -1241,7 +1102,7 @@ test("model alias name defaults to alias key when id differs", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet")
|
expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet")
|
||||||
},
|
},
|
||||||
@@ -1279,7 +1140,7 @@ test("provider with multiple env var options only includes apiKey when single en
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "MULTI_ENV_KEY_1", "test-key")
|
await set(ctx, "MULTI_ENV_KEY_1", "test-key")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
expect(providers[ProviderID.make("multi-env")]).toBeDefined()
|
expect(providers[ProviderID.make("multi-env")]).toBeDefined()
|
||||||
// When multiple env options exist, key should NOT be auto-set
|
// When multiple env options exist, key should NOT be auto-set
|
||||||
@@ -1319,7 +1180,7 @@ test("provider with single env var includes apiKey automatically", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "SINGLE_ENV_KEY", "my-api-key")
|
await set(ctx, "SINGLE_ENV_KEY", "my-api-key")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
expect(providers[ProviderID.make("single-env")]).toBeDefined()
|
expect(providers[ProviderID.make("single-env")]).toBeDefined()
|
||||||
// Single env option should auto-set key
|
// Single env option should auto-set key
|
||||||
@@ -1354,7 +1215,7 @@ test("model cost overrides existing cost values", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||||
expect(model.cost.input).toBe(999)
|
expect(model.cost.input).toBe(999)
|
||||||
@@ -1431,9 +1292,9 @@ test("disabled_providers and enabled_providers interaction", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-anthropic")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-anthropic")
|
||||||
set(ctx, "OPENAI_API_KEY", "test-openai")
|
await set(ctx, "OPENAI_API_KEY", "test-openai")
|
||||||
set(ctx, "GOOGLE_GENERATIVE_AI_API_KEY", "test-google")
|
await set(ctx, "GOOGLE_GENERATIVE_AI_API_KEY", "test-google")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
// anthropic: in enabled, not in disabled = allowed
|
// anthropic: in enabled, not in disabled = allowed
|
||||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||||
@@ -1588,7 +1449,7 @@ test("provider env fallback - second env var used if first missing", async () =>
|
|||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
// Only set fallback, not primary
|
// Only set fallback, not primary
|
||||||
set(ctx, "FALLBACK_KEY", "fallback-api-key")
|
await set(ctx, "FALLBACK_KEY", "fallback-api-key")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
// Provider should load because fallback env var is set
|
// Provider should load because fallback env var is set
|
||||||
expect(providers[ProviderID.make("fallback-env")]).toBeDefined()
|
expect(providers[ProviderID.make("fallback-env")]).toBeDefined()
|
||||||
@@ -1610,7 +1471,7 @@ test("getModel returns consistent results", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"), ctx)
|
const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"), ctx)
|
||||||
const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"), ctx)
|
const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"), ctx)
|
||||||
expect(model1.providerID).toEqual(model2.providerID)
|
expect(model1.providerID).toEqual(model2.providerID)
|
||||||
@@ -1669,7 +1530,7 @@ test("ModelNotFoundError includes suggestions for typos", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
try {
|
try {
|
||||||
await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4"), ctx) // typo: sonet instead of sonnet
|
await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4"), ctx) // typo: sonet instead of sonnet
|
||||||
expect(true).toBe(false) // Should not reach here
|
expect(true).toBe(false) // Should not reach here
|
||||||
@@ -1695,7 +1556,7 @@ test("ModelNotFoundError for provider includes suggestions", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
try {
|
try {
|
||||||
await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4"), ctx) // typo: antropic
|
await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4"), ctx) // typo: antropic
|
||||||
expect(true).toBe(false) // Should not reach here
|
expect(true).toBe(false) // Should not reach here
|
||||||
@@ -1721,7 +1582,7 @@ test("ModelNotFoundError suggests catalog models for unloaded providers", async
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
remove(ctx, "OPENCODE_API_KEY")
|
await remove(ctx, "OPENCODE_API_KEY")
|
||||||
try {
|
try {
|
||||||
await getModel(ProviderID.opencode, ModelID.make("claude-haiku-fake-model"), ctx)
|
await getModel(ProviderID.opencode, ModelID.make("claude-haiku-fake-model"), ctx)
|
||||||
throw new Error("expected model lookup to fail")
|
throw new Error("expected model lookup to fail")
|
||||||
@@ -1767,7 +1628,7 @@ test("getProvider returns provider info", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const provider = await getProvider(ProviderID.anthropic, ctx)
|
const provider = await getProvider(ProviderID.anthropic, ctx)
|
||||||
expect(provider).toBeDefined()
|
expect(provider).toBeDefined()
|
||||||
expect(String(provider?.id)).toBe("anthropic")
|
expect(String(provider?.id)).toBe("anthropic")
|
||||||
@@ -1789,7 +1650,7 @@ test("closest returns undefined when no partial match found", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"], ctx)
|
const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"], ctx)
|
||||||
expect(result).toBeUndefined()
|
expect(result).toBeUndefined()
|
||||||
},
|
},
|
||||||
@@ -1810,7 +1671,7 @@ test("closest checks multiple query terms in order", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
// First term won't match, second will
|
// First term won't match, second will
|
||||||
const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"], ctx)
|
const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"], ctx)
|
||||||
expect(result).toBeDefined()
|
expect(result).toBeDefined()
|
||||||
@@ -1880,7 +1741,7 @@ test("provider options are deeply merged", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
// Custom options should be merged
|
// Custom options should be merged
|
||||||
expect(providers[ProviderID.anthropic].options.timeout).toBe(30000)
|
expect(providers[ProviderID.anthropic].options.timeout).toBe(30000)
|
||||||
@@ -2010,7 +1871,7 @@ test("custom model inherits npm package from models.dev provider config", async
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "OPENAI_API_KEY", "test-api-key")
|
await set(ctx, "OPENAI_API_KEY", "test-api-key")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
const model = providers[ProviderID.openai].models["my-custom-model"]
|
const model = providers[ProviderID.openai].models["my-custom-model"]
|
||||||
expect(model).toBeDefined()
|
expect(model).toBeDefined()
|
||||||
@@ -2043,7 +1904,7 @@ test("custom model inherits api.url from models.dev provider", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "OPENROUTER_API_KEY", "test-api-key")
|
await set(ctx, "OPENROUTER_API_KEY", "test-api-key")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
expect(providers[ProviderID.openrouter]).toBeDefined()
|
expect(providers[ProviderID.openrouter]).toBeDefined()
|
||||||
|
|
||||||
@@ -2174,7 +2035,7 @@ test("model variants are generated for reasoning models", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
// Claude sonnet 4 has reasoning capability
|
// Claude sonnet 4 has reasoning capability
|
||||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||||
@@ -2210,7 +2071,7 @@ test("model variants can be disabled via config", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||||
expect(model.variants).toBeDefined()
|
expect(model.variants).toBeDefined()
|
||||||
@@ -2251,7 +2112,7 @@ test("model variants can be customized via config", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||||
expect(model.variants!["high"]).toBeDefined()
|
expect(model.variants!["high"]).toBeDefined()
|
||||||
@@ -2288,7 +2149,7 @@ test("disabled key is stripped from variant config", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||||
expect(model.variants!["max"]).toBeDefined()
|
expect(model.variants!["max"]).toBeDefined()
|
||||||
@@ -2324,7 +2185,7 @@ test("all variants can be disabled via config", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||||
expect(model.variants).toBeDefined()
|
expect(model.variants).toBeDefined()
|
||||||
@@ -2360,7 +2221,7 @@ test("variant config merges with generated variants", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||||
expect(model.variants!["high"]).toBeDefined()
|
expect(model.variants!["high"]).toBeDefined()
|
||||||
@@ -2396,7 +2257,7 @@ test("variants filtered in second pass for database models", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "OPENAI_API_KEY", "test-api-key")
|
await set(ctx, "OPENAI_API_KEY", "test-api-key")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
const model = providers[ProviderID.openai].models["gpt-5"]
|
const model = providers[ProviderID.openai].models["gpt-5"]
|
||||||
expect(model.variants).toBeDefined()
|
expect(model.variants).toBeDefined()
|
||||||
@@ -2498,7 +2359,7 @@ test("Google Vertex: retains baseURL for custom proxy", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
|
await set(ctx, "GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined()
|
expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined()
|
||||||
expect(providers[ProviderID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1")
|
expect(providers[ProviderID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1")
|
||||||
@@ -2541,7 +2402,7 @@ test("Google Vertex: supports OpenAI compatible models", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
|
await set(ctx, "GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"]
|
const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"]
|
||||||
|
|
||||||
@@ -2565,9 +2426,9 @@ test("cloudflare-ai-gateway loads with env variables", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "CLOUDFLARE_ACCOUNT_ID", "test-account")
|
await set(ctx, "CLOUDFLARE_ACCOUNT_ID", "test-account")
|
||||||
set(ctx, "CLOUDFLARE_GATEWAY_ID", "test-gateway")
|
await set(ctx, "CLOUDFLARE_GATEWAY_ID", "test-gateway")
|
||||||
set(ctx, "CLOUDFLARE_API_TOKEN", "test-token")
|
await set(ctx, "CLOUDFLARE_API_TOKEN", "test-token")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
|
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
|
||||||
},
|
},
|
||||||
@@ -2595,9 +2456,9 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "CLOUDFLARE_ACCOUNT_ID", "test-account")
|
await set(ctx, "CLOUDFLARE_ACCOUNT_ID", "test-account")
|
||||||
set(ctx, "CLOUDFLARE_GATEWAY_ID", "test-gateway")
|
await set(ctx, "CLOUDFLARE_GATEWAY_ID", "test-gateway")
|
||||||
set(ctx, "CLOUDFLARE_API_TOKEN", "test-token")
|
await set(ctx, "CLOUDFLARE_API_TOKEN", "test-token")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
|
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
|
||||||
expect(providers[ProviderID.make("cloudflare-ai-gateway")].options.metadata).toEqual({
|
expect(providers[ProviderID.make("cloudflare-ai-gateway")].options.metadata).toEqual({
|
||||||
@@ -2673,8 +2534,10 @@ test("plugin config providers persist after instance dispose", async () => {
|
|||||||
test("plugin config enabled and disabled providers are honored", async () => {
|
test("plugin config enabled and disabled providers are honored", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
const root = path.join(dir, ".opencode", "plugin")
|
const configDir = path.join(dir, ".opencode")
|
||||||
|
const root = path.join(configDir, "plugin")
|
||||||
await mkdir(root, { recursive: true })
|
await mkdir(root, { recursive: true })
|
||||||
|
await markPluginDependenciesReady(configDir)
|
||||||
await Bun.write(
|
await Bun.write(
|
||||||
path.join(root, "provider-filter.ts"),
|
path.join(root, "provider-filter.ts"),
|
||||||
[
|
[
|
||||||
@@ -2696,8 +2559,8 @@ test("plugin config enabled and disabled providers are honored", async () => {
|
|||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async (ctx) => {
|
fn: async (ctx) => {
|
||||||
set(ctx, "ANTHROPIC_API_KEY", "test-anthropic-key")
|
await set(ctx, "ANTHROPIC_API_KEY", "test-anthropic-key")
|
||||||
set(ctx, "OPENAI_API_KEY", "test-openai-key")
|
await set(ctx, "OPENAI_API_KEY", "test-openai-key")
|
||||||
const providers = await list(ctx)
|
const providers = await list(ctx)
|
||||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||||
expect(providers[ProviderID.openai]).toBeUndefined()
|
expect(providers[ProviderID.openai]).toBeUndefined()
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
|||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Config } from "../../src/config/config"
|
import { Config } from "../../src/config/config"
|
||||||
|
import { ConfigReference } from "../../src/config/reference"
|
||||||
import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
||||||
import { Git } from "../../src/git"
|
import { Git } from "../../src/git"
|
||||||
import { Reference } from "../../src/reference/reference"
|
import { Reference } from "../../src/reference/reference"
|
||||||
|
import { RepositoryCache } from "../../src/reference/repository-cache"
|
||||||
import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
@@ -18,8 +20,7 @@ afterEach(async () => {
|
|||||||
const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||||
Reference.layer.pipe(
|
Reference.layer.pipe(
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(RepositoryCache.defaultLayer),
|
||||||
Layer.provide(Git.defaultLayer),
|
|
||||||
Layer.provide(RuntimeFlags.layer(flags)),
|
Layer.provide(RuntimeFlags.layer(flags)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -81,44 +82,89 @@ const waitForContent = (
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("reference", () => {
|
describe("reference", () => {
|
||||||
it.live("resolves local and git references", () =>
|
it.live("resolves supported local and git config forms", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const root = path.resolve("opencode-reference-root")
|
const root = path.resolve("opencode-reference-root")
|
||||||
const local = Reference.resolve({
|
const local = Reference.resolve({
|
||||||
name: "docs",
|
name: "docs",
|
||||||
reference: { path: "../docs" },
|
reference: ConfigReference.normalizeEntry({ path: "../docs" }),
|
||||||
directory: path.join(root, "packages", "app"),
|
directory: path.join(root, "packages", "app"),
|
||||||
worktree: root,
|
worktree: root,
|
||||||
})
|
})
|
||||||
const repo = Reference.resolve({
|
const repo = Reference.resolve({
|
||||||
name: "effect",
|
name: "effect",
|
||||||
reference: { repository: "Effect-TS/effect", branch: "main" },
|
reference: ConfigReference.normalizeEntry({ repository: "Effect-TS/effect", branch: "main" }),
|
||||||
|
directory: path.join(root, "packages", "app"),
|
||||||
|
worktree: root,
|
||||||
|
})
|
||||||
|
const localString = Reference.resolve({
|
||||||
|
name: "notes",
|
||||||
|
reference: ConfigReference.normalizeEntry("./notes"),
|
||||||
|
directory: path.join(root, "packages", "app"),
|
||||||
|
worktree: root,
|
||||||
|
})
|
||||||
|
const repoString = Reference.resolve({
|
||||||
|
name: "repo",
|
||||||
|
reference: ConfigReference.normalizeEntry("owner/repo"),
|
||||||
directory: path.join(root, "packages", "app"),
|
directory: path.join(root, "packages", "app"),
|
||||||
worktree: root,
|
worktree: root,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(local.kind).toBe("local")
|
expect(local.kind).toBe("local")
|
||||||
if (local.kind === "local") expect(local.path).toBe(path.resolve(root, "../docs"))
|
if (local.kind === "local") expect(local.path).toBe(path.resolve(root, "../docs"))
|
||||||
|
expect(localString.kind).toBe("local")
|
||||||
|
if (localString.kind === "local") expect(localString.path).toBe(path.resolve(root, "notes"))
|
||||||
expect(repo.kind).toBe("git")
|
expect(repo.kind).toBe("git")
|
||||||
if (repo.kind === "git") {
|
if (repo.kind === "git") {
|
||||||
expect(repo.repository).toBe("Effect-TS/effect")
|
expect(repo.repository).toBe("Effect-TS/effect")
|
||||||
expect(repo.branch).toBe("main")
|
expect(repo.branch).toBe("main")
|
||||||
expect(repo.path).toBe(path.join(Global.Path.repos, "github.com", "Effect-TS", "effect"))
|
expect(repo.path).toBe(path.join(Global.Path.repos, "github.com", "Effect-TS", "effect"))
|
||||||
}
|
}
|
||||||
|
expect(repoString.kind).toBe("git")
|
||||||
|
if (repoString.kind === "git") {
|
||||||
|
expect(repoString.repository).toBe("owner/repo")
|
||||||
|
expect(repoString.path).toBe(path.join(Global.Path.repos, "github.com", "owner", "repo"))
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("keeps invalid repository references visible without materializing", () =>
|
||||||
|
provideTmpdirInstance(
|
||||||
|
(_dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const reference = yield* Reference.Service
|
||||||
|
const references = yield* reference.list()
|
||||||
|
const invalid = yield* reference.get("bad")
|
||||||
|
|
||||||
|
expect(references.map((item) => item.name)).toEqual(["bad"])
|
||||||
|
expect(invalid).toMatchObject({
|
||||||
|
name: "bad",
|
||||||
|
kind: "invalid",
|
||||||
|
repository: "not-a-repo",
|
||||||
|
})
|
||||||
|
if (invalid?.kind === "invalid") expect(invalid.message).toContain("Repository must be a git URL")
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
reference: {
|
||||||
|
bad: "not-a-repo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("marks same-cache references with different branches invalid", () =>
|
it.live("marks same-cache references with different branches invalid", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const root = path.resolve("opencode-reference-root")
|
const root = path.resolve("opencode-reference-root")
|
||||||
const references = Reference.resolveAll({
|
const references = Reference.resolveAll({
|
||||||
directory: root,
|
directory: root,
|
||||||
worktree: root,
|
worktree: root,
|
||||||
references: {
|
references: ConfigReference.normalize({
|
||||||
main: { repository: "owner/repo", branch: "main" },
|
main: { repository: "owner/repo", branch: "main" },
|
||||||
dev: { repository: "github.com/owner/repo", branch: "dev" },
|
dev: { repository: "github.com/owner/repo", branch: "dev" },
|
||||||
alsoMain: { repository: "https://github.com/owner/repo", branch: "main" },
|
alsoMain: { repository: "https://github.com/owner/repo", branch: "main" },
|
||||||
},
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(references.map((reference) => reference.kind)).toEqual(["git", "invalid", "git"])
|
expect(references.map((reference) => reference.kind)).toEqual(["git", "invalid", "git"])
|
||||||
@@ -130,6 +176,27 @@ describe("reference", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("represents invalid aliases as invalid references", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const root = path.resolve("opencode-reference-root")
|
||||||
|
const references = Reference.resolveAll({
|
||||||
|
directory: root,
|
||||||
|
worktree: root,
|
||||||
|
references: ConfigReference.normalize({
|
||||||
|
"bad/name": "owner/repo",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(references).toEqual([
|
||||||
|
{
|
||||||
|
name: "bad/name",
|
||||||
|
kind: "invalid",
|
||||||
|
message: "Reference alias must not contain /, whitespace, comma, or backtick",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
scout.live("materializes configured git references during init", () =>
|
scout.live("materializes configured git references during init", () =>
|
||||||
provideTmpdirInstance(
|
provideTmpdirInstance(
|
||||||
(_dir) =>
|
(_dir) =>
|
||||||
|
|||||||
@@ -0,0 +1,443 @@
|
|||||||
|
// Diagnostic suite for /event SSE delivery.
|
||||||
|
//
|
||||||
|
// Each test isolates ONE variable in the publisher chain while keeping the
|
||||||
|
// subscriber path constant (raw `app().request` reading the SSE body — no SDK
|
||||||
|
// consumer involvement). The pass/fail pattern across tests tells us where the
|
||||||
|
// bug lives:
|
||||||
|
//
|
||||||
|
// D1 (baseline): publish via Bus.Service.use via AppRuntime — mirror of the
|
||||||
|
// existing httpapi-event.test.ts test 3. Confirms /event SSE delivery
|
||||||
|
// works for a SOME publish path.
|
||||||
|
//
|
||||||
|
// D2: publish N times in quick succession via Bus.Service.use. If the bus
|
||||||
|
// subscription is acquired correctly there should be no message loss.
|
||||||
|
//
|
||||||
|
// D3: publish via SyncEvent.use.run via AppRuntime — exercises the same path
|
||||||
|
// the HTTP handlers use (Session.updatePart → sync.run → bus.publish)
|
||||||
|
// without the HTTP roundtrip. Tells us whether the sync path itself can
|
||||||
|
// deliver in-process.
|
||||||
|
//
|
||||||
|
// D4: publish via SyncEvent.use.run from a fresh `Effect.provide` scope
|
||||||
|
// (mimicking what happens if a handler's layer was scoped per-request).
|
||||||
|
//
|
||||||
|
// D5: in-process Bus.Service callback subscriber AND raw /event SSE subscriber
|
||||||
|
// receive the same publish. If both receive: no bug. If only the
|
||||||
|
// callback receives: the /event handler has an acquisition race.
|
||||||
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
import { Bus } from "../../src/bus"
|
||||||
|
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||||
|
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||||
|
import { Server } from "../../src/server/server"
|
||||||
|
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
|
||||||
|
import { Event as ServerEvent } from "../../src/server/event"
|
||||||
|
import { SyncEvent } from "../../src/sync"
|
||||||
|
import { MessageV2 } from "../../src/session/message-v2"
|
||||||
|
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||||
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
|
import { Effect, Schema } from "effect"
|
||||||
|
import { resetDatabase } from "../fixture/db"
|
||||||
|
import { disposeAllInstances, reloadTestInstance, tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
void Log.init({ print: false })
|
||||||
|
|
||||||
|
function app() {
|
||||||
|
return Server.Default().app
|
||||||
|
}
|
||||||
|
|
||||||
|
const EventData = Schema.Struct({
|
||||||
|
id: Schema.optional(Schema.String),
|
||||||
|
type: Schema.String,
|
||||||
|
properties: Schema.Record(Schema.String, Schema.Any),
|
||||||
|
})
|
||||||
|
|
||||||
|
type SseEvent = Schema.Schema.Type<typeof EventData>
|
||||||
|
|
||||||
|
async function readChunk(reader: ReadableStreamDefaultReader<Uint8Array>, timeoutMs = 3_000) {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
reader.read(),
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
timeout = setTimeout(() => reject(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
if (timeout) clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const textDecoder = new TextDecoder()
|
||||||
|
|
||||||
|
function decodeFrame(value: Uint8Array): SseEvent[] {
|
||||||
|
// SSE frames are separated by blank lines and each starts with "data: ".
|
||||||
|
// For our happy-path tests one chunk == one frame, but be defensive.
|
||||||
|
const text = textDecoder.decode(value)
|
||||||
|
return text
|
||||||
|
.split(/\n\n+/)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter((part) => part.length > 0)
|
||||||
|
.map((part) => {
|
||||||
|
const payload = part.replace(/^data: /, "")
|
||||||
|
return Schema.decodeUnknownSync(EventData)(JSON.parse(payload))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readNextEvent(reader: ReadableStreamDefaultReader<Uint8Array>, timeoutMs = 3_000): Promise<SseEvent> {
|
||||||
|
const result = await readChunk(reader, timeoutMs)
|
||||||
|
if (result.done || !result.value) throw new Error("event stream closed")
|
||||||
|
const frames = decodeFrame(result.value)
|
||||||
|
if (frames.length === 0) throw new Error("empty SSE frame")
|
||||||
|
return frames[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectUntil(
|
||||||
|
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||||
|
predicate: (event: SseEvent) => boolean,
|
||||||
|
timeoutMs = 3_000,
|
||||||
|
): Promise<SseEvent[]> {
|
||||||
|
const events: SseEvent[] = []
|
||||||
|
const deadline = Date.now() + timeoutMs
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const remaining = deadline - Date.now()
|
||||||
|
const result = await readChunk(reader, remaining).catch((cause) => {
|
||||||
|
throw new Error(`collectUntil timed out after ${events.length} events: ${cause}`)
|
||||||
|
})
|
||||||
|
if (result.done || !result.value) throw new Error("event stream closed mid-collect")
|
||||||
|
for (const event of decodeFrame(result.value)) {
|
||||||
|
events.push(event)
|
||||||
|
if (predicate(event)) return events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`collectUntil deadline exceeded; collected ${events.length}: ${JSON.stringify(events)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await disposeAllInstances()
|
||||||
|
await resetDatabase()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("/event SSE delivery diagnostics", () => {
|
||||||
|
// Sanity: baseline same as httpapi-event.test.ts test 3 (already known to pass)
|
||||||
|
// but explicit about timing — publish happens with NO wait after reading
|
||||||
|
// server.connected. If this fails we have a deeper problem than just sync.
|
||||||
|
test("D1: delivers a single bus event published right after server.connected", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||||
|
const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } })
|
||||||
|
if (!response.body) throw new Error("missing response body")
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
try {
|
||||||
|
const first = await readNextEvent(reader)
|
||||||
|
expect(first.type).toBe("server.connected")
|
||||||
|
|
||||||
|
const ctx = await reloadTestInstance({ directory: tmp.path })
|
||||||
|
// NO wait — publish immediately
|
||||||
|
await AppRuntime.runPromise(
|
||||||
|
Bus.Service.use((svc) => svc.publish(ServerEvent.Connected, {})).pipe(Effect.provideService(InstanceRef, ctx)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const next = await readNextEvent(reader)
|
||||||
|
expect(next.type).toBe("server.connected") // ServerEvent.Connected.type === "server.connected"
|
||||||
|
} finally {
|
||||||
|
await reader.cancel()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// If D1 passes but D2 fails, we have a queue-drain or partial-loss issue.
|
||||||
|
test("D2: delivers all N bus events published in rapid succession", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||||
|
const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } })
|
||||||
|
if (!response.body) throw new Error("missing response body")
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
try {
|
||||||
|
const first = await readNextEvent(reader)
|
||||||
|
expect(first.type).toBe("server.connected")
|
||||||
|
|
||||||
|
const ctx = await reloadTestInstance({ directory: tmp.path })
|
||||||
|
const N = 5
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
await AppRuntime.runPromise(
|
||||||
|
Bus.Service.use((svc) => svc.publish(ServerEvent.Connected, {})).pipe(
|
||||||
|
Effect.provideService(InstanceRef, ctx),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const received: SseEvent[] = []
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
received.push(await readNextEvent(reader))
|
||||||
|
}
|
||||||
|
expect(received).toHaveLength(N)
|
||||||
|
for (const event of received) expect(event.type).toBe("server.connected")
|
||||||
|
} finally {
|
||||||
|
await reader.cancel()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// The critical test. If D1 passes but this fails, the bus-identity fix is
|
||||||
|
// incomplete OR the sync.run publish path doesn't reach the same bus
|
||||||
|
// /event subscribes to, even within the same AppRuntime.
|
||||||
|
test("D3: delivers a SyncEvent published via SyncEvent.use.run after server.connected", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||||
|
const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } })
|
||||||
|
if (!response.body) throw new Error("missing response body")
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
try {
|
||||||
|
const first = await readNextEvent(reader)
|
||||||
|
expect(first.type).toBe("server.connected")
|
||||||
|
|
||||||
|
const ctx = await reloadTestInstance({ directory: tmp.path })
|
||||||
|
const sessionID = SessionID.make(`ses_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`)
|
||||||
|
const messageID = MessageID.ascending()
|
||||||
|
const partID = PartID.ascending()
|
||||||
|
const part: MessageV2.Part = {
|
||||||
|
id: partID,
|
||||||
|
sessionID,
|
||||||
|
messageID,
|
||||||
|
type: "text",
|
||||||
|
text: "diag",
|
||||||
|
}
|
||||||
|
|
||||||
|
await AppRuntime.runPromise(
|
||||||
|
SyncEvent.use
|
||||||
|
.run(MessageV2.Event.PartUpdated, {
|
||||||
|
sessionID,
|
||||||
|
part: structuredClone(part) as MessageV2.Part,
|
||||||
|
time: Date.now(),
|
||||||
|
})
|
||||||
|
.pipe(Effect.provideService(InstanceRef, ctx)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const collected = await collectUntil(reader, (event) => event.type === MessageV2.Event.PartUpdated.type, 4_000)
|
||||||
|
const updated = collected.find((event) => event.type === MessageV2.Event.PartUpdated.type)
|
||||||
|
expect(updated).toBeDefined()
|
||||||
|
expect((updated as any).properties.part.id).toBe(partID)
|
||||||
|
} finally {
|
||||||
|
await reader.cancel()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// If D3 passes but D5 (the SDK E2E in httpapi-sdk.test.ts) fails, then the
|
||||||
|
// bug is specifically in the cross-request / cross-fiber HTTP path, not in
|
||||||
|
// the publish itself. If D3 also fails, the publish chain is broken.
|
||||||
|
//
|
||||||
|
// D4: ensure the publish reaches an in-process Bus subscriber too. Confirms
|
||||||
|
// pub/sub identity end-to-end without involving /event SSE.
|
||||||
|
test("D4: SyncEvent.use.run publish reaches an in-process Bus.Service.use callback", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||||
|
const ctx = await reloadTestInstance({ directory: tmp.path })
|
||||||
|
|
||||||
|
let resolveReceived: (event: { id: string; type: string; properties: unknown }) => void
|
||||||
|
const received = new Promise<{ id: string; type: string; properties: unknown }>(
|
||||||
|
(resolve) => (resolveReceived = resolve as typeof resolveReceived),
|
||||||
|
)
|
||||||
|
|
||||||
|
const dispose = await AppRuntime.runPromise(
|
||||||
|
Bus.Service.use((svc) =>
|
||||||
|
svc.subscribeAllCallback((event) => {
|
||||||
|
if (event.type === MessageV2.Event.PartUpdated.type) resolveReceived(event)
|
||||||
|
}),
|
||||||
|
).pipe(Effect.provideService(InstanceRef, ctx)),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionID = SessionID.make(`ses_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`)
|
||||||
|
const messageID = MessageID.ascending()
|
||||||
|
const partID = PartID.ascending()
|
||||||
|
const part: MessageV2.Part = { id: partID, sessionID, messageID, type: "text", text: "diag-d4" }
|
||||||
|
|
||||||
|
await AppRuntime.runPromise(
|
||||||
|
SyncEvent.use
|
||||||
|
.run(MessageV2.Event.PartUpdated, {
|
||||||
|
sessionID,
|
||||||
|
part: structuredClone(part) as MessageV2.Part,
|
||||||
|
time: Date.now(),
|
||||||
|
})
|
||||||
|
.pipe(Effect.provideService(InstanceRef, ctx)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const event = await Promise.race([
|
||||||
|
received,
|
||||||
|
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("D4 timed out")), 3_000)),
|
||||||
|
])
|
||||||
|
expect(event.type).toBe(MessageV2.Event.PartUpdated.type)
|
||||||
|
expect((event.properties as any).part.id).toBe(partID)
|
||||||
|
} finally {
|
||||||
|
dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// D5: BOTH subscribers attached simultaneously. Trigger ONE publish via
|
||||||
|
// SyncEvent.use.run. Both subscribers should receive it. If only one does
|
||||||
|
// we know exactly which side of the chain is failing.
|
||||||
|
test("D5: same SyncEvent.use.run publish reaches BOTH /event SSE and in-process callback", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||||
|
const ctx = await reloadTestInstance({ directory: tmp.path })
|
||||||
|
|
||||||
|
// In-process callback subscriber
|
||||||
|
let resolveCallback: (event: { type: string; properties: unknown }) => void
|
||||||
|
const callbackReceived = new Promise<{ type: string; properties: unknown }>(
|
||||||
|
(resolve) => (resolveCallback = resolve as typeof resolveCallback),
|
||||||
|
)
|
||||||
|
const dispose = await AppRuntime.runPromise(
|
||||||
|
Bus.Service.use((svc) =>
|
||||||
|
svc.subscribeAllCallback((event) => {
|
||||||
|
if (event.type === MessageV2.Event.PartUpdated.type) resolveCallback(event)
|
||||||
|
}),
|
||||||
|
).pipe(Effect.provideService(InstanceRef, ctx)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSE subscriber via raw HTTP
|
||||||
|
const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } })
|
||||||
|
if (!response.body) throw new Error("missing response body")
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const first = await readNextEvent(reader)
|
||||||
|
expect(first.type).toBe("server.connected")
|
||||||
|
|
||||||
|
const sessionID = SessionID.make(`ses_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`)
|
||||||
|
const messageID = MessageID.ascending()
|
||||||
|
const partID = PartID.ascending()
|
||||||
|
const part: MessageV2.Part = { id: partID, sessionID, messageID, type: "text", text: "diag-d5" }
|
||||||
|
|
||||||
|
await AppRuntime.runPromise(
|
||||||
|
SyncEvent.use
|
||||||
|
.run(MessageV2.Event.PartUpdated, {
|
||||||
|
sessionID,
|
||||||
|
part: structuredClone(part) as MessageV2.Part,
|
||||||
|
time: Date.now(),
|
||||||
|
})
|
||||||
|
.pipe(Effect.provideService(InstanceRef, ctx)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const sseCollected = await collectUntil(
|
||||||
|
reader,
|
||||||
|
(event) => event.type === MessageV2.Event.PartUpdated.type,
|
||||||
|
4_000,
|
||||||
|
).catch((err) => err as Error)
|
||||||
|
const callbackResult = await Promise.race([
|
||||||
|
callbackReceived,
|
||||||
|
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 1_000)),
|
||||||
|
])
|
||||||
|
|
||||||
|
const sseSaw =
|
||||||
|
Array.isArray(sseCollected) && sseCollected.some((event) => event.type === MessageV2.Event.PartUpdated.type)
|
||||||
|
const callbackSaw = callbackResult !== "timeout"
|
||||||
|
|
||||||
|
// Both should see it. The reason we use a single assert with the boolean
|
||||||
|
// pair is so the test failure message tells us exactly which side broke.
|
||||||
|
expect({ sseSaw, callbackSaw }).toEqual({ sseSaw: true, callbackSaw: true })
|
||||||
|
} finally {
|
||||||
|
await reader.cancel()
|
||||||
|
dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// D7: like D5 but the "second subscriber" is a NO-OP AppRuntime.runPromise
|
||||||
|
// call (no PubSub.subscribe). If D7 passes, the specific subscribeAllCallback
|
||||||
|
// is what breaks SSE — not arbitrary AppRuntime usage. If D7 fails, anything
|
||||||
|
// running through AppRuntime concurrently with /event SSE breaks delivery.
|
||||||
|
test("D7: SSE receives sync.run publish even with concurrent no-op AppRuntime activity", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||||
|
const ctx = await reloadTestInstance({ directory: tmp.path })
|
||||||
|
|
||||||
|
// No-op: just touches the runtime, no bus interaction
|
||||||
|
await AppRuntime.runPromise(Effect.void)
|
||||||
|
|
||||||
|
const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } })
|
||||||
|
if (!response.body) throw new Error("missing response body")
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
try {
|
||||||
|
const first = await readNextEvent(reader)
|
||||||
|
expect(first.type).toBe("server.connected")
|
||||||
|
|
||||||
|
const sessionID = SessionID.make(`ses_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`)
|
||||||
|
const messageID = MessageID.ascending()
|
||||||
|
const partID = PartID.ascending()
|
||||||
|
const part: MessageV2.Part = { id: partID, sessionID, messageID, type: "text", text: "diag-d7" }
|
||||||
|
|
||||||
|
await AppRuntime.runPromise(
|
||||||
|
SyncEvent.use
|
||||||
|
.run(MessageV2.Event.PartUpdated, {
|
||||||
|
sessionID,
|
||||||
|
part: structuredClone(part) as MessageV2.Part,
|
||||||
|
time: Date.now(),
|
||||||
|
})
|
||||||
|
.pipe(Effect.provideService(InstanceRef, ctx)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const collected = await collectUntil(reader, (event) => event.type === MessageV2.Event.PartUpdated.type, 4_000)
|
||||||
|
const updated = collected.find((event) => event.type === MessageV2.Event.PartUpdated.type)
|
||||||
|
expect(updated).toBeDefined()
|
||||||
|
} finally {
|
||||||
|
await reader.cancel()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// D6: same as D5 but the callback subscriber is attached AFTER /event SSE
|
||||||
|
// subscription is established. If D5 fails and D6 passes, the order of
|
||||||
|
// subscriber setup is the determining factor.
|
||||||
|
test("D6: /event SSE receives sync.run publish when callback is attached AFTER /event opens", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||||
|
const ctx = await reloadTestInstance({ directory: tmp.path })
|
||||||
|
|
||||||
|
// Open SSE FIRST
|
||||||
|
const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } })
|
||||||
|
if (!response.body) throw new Error("missing response body")
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const first = await readNextEvent(reader)
|
||||||
|
expect(first.type).toBe("server.connected")
|
||||||
|
|
||||||
|
// THEN attach callback subscriber
|
||||||
|
let resolveCallback: (event: { type: string; properties: unknown }) => void
|
||||||
|
const callbackReceived = new Promise<{ type: string; properties: unknown }>(
|
||||||
|
(resolve) => (resolveCallback = resolve as typeof resolveCallback),
|
||||||
|
)
|
||||||
|
const dispose = await AppRuntime.runPromise(
|
||||||
|
Bus.Service.use((svc) =>
|
||||||
|
svc.subscribeAllCallback((event) => {
|
||||||
|
if (event.type === MessageV2.Event.PartUpdated.type) resolveCallback(event)
|
||||||
|
}),
|
||||||
|
).pipe(Effect.provideService(InstanceRef, ctx)),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionID = SessionID.make(`ses_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`)
|
||||||
|
const messageID = MessageID.ascending()
|
||||||
|
const partID = PartID.ascending()
|
||||||
|
const part: MessageV2.Part = { id: partID, sessionID, messageID, type: "text", text: "diag-d6" }
|
||||||
|
|
||||||
|
await AppRuntime.runPromise(
|
||||||
|
SyncEvent.use
|
||||||
|
.run(MessageV2.Event.PartUpdated, {
|
||||||
|
sessionID,
|
||||||
|
part: structuredClone(part) as MessageV2.Part,
|
||||||
|
time: Date.now(),
|
||||||
|
})
|
||||||
|
.pipe(Effect.provideService(InstanceRef, ctx)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const sseCollected = await collectUntil(
|
||||||
|
reader,
|
||||||
|
(event) => event.type === MessageV2.Event.PartUpdated.type,
|
||||||
|
4_000,
|
||||||
|
).catch((err) => err as Error)
|
||||||
|
const callbackResult = await Promise.race([
|
||||||
|
callbackReceived,
|
||||||
|
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 1_000)),
|
||||||
|
])
|
||||||
|
|
||||||
|
const sseSaw =
|
||||||
|
Array.isArray(sseCollected) && sseCollected.some((event) => event.type === MessageV2.Event.PartUpdated.type)
|
||||||
|
const callbackSaw = callbackResult !== "timeout"
|
||||||
|
expect({ sseSaw, callbackSaw }).toEqual({ sseSaw: true, callbackSaw: true })
|
||||||
|
} finally {
|
||||||
|
dispose()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await reader.cancel()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -169,7 +169,7 @@ async function openPtySocket(listener: Awaited<ReturnType<typeof startListener>>
|
|||||||
|
|
||||||
describe("HttpApi Server.listen", () => {
|
describe("HttpApi Server.listen", () => {
|
||||||
testPty("serves HTTP routes and upgrades PTY websocket through Server.listen", async () => {
|
testPty("serves HTTP routes and upgrades PTY websocket through Server.listen", async () => {
|
||||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||||
const listener = await startListener()
|
const listener = await startListener()
|
||||||
let stopped = false
|
let stopped = false
|
||||||
try {
|
try {
|
||||||
@@ -330,7 +330,7 @@ describe("HttpApi Server.listen", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
testPty("rejects unsafe PTY ticket mint and connect requests", async () => {
|
testPty("rejects unsafe PTY ticket mint and connect requests", async () => {
|
||||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||||
const listener = await startListener()
|
const listener = await startListener()
|
||||||
try {
|
try {
|
||||||
const info = await createCat(listener, tmp.path)
|
const info = await createCat(listener, tmp.path)
|
||||||
@@ -338,6 +338,29 @@ describe("HttpApi Server.listen", () => {
|
|||||||
expect((await requestTicket(listener, info.id, tmp.path, { ticketHeader: false })).status).toBe(403)
|
expect((await requestTicket(listener, info.id, tmp.path, { ticketHeader: false })).status).toBe(403)
|
||||||
expect((await requestTicket(listener, info.id, tmp.path, { origin: "https://evil.example" })).status).toBe(403)
|
expect((await requestTicket(listener, info.id, tmp.path, { origin: "https://evil.example" })).status).toBe(403)
|
||||||
|
|
||||||
|
// Regression for #25698: minting without a directory uses the server cwd
|
||||||
|
// and cannot find a PTY registered in a project directory.
|
||||||
|
const ambiguous = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", info.id), listener.url), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: authorization(), "x-opencode-ticket": "1" },
|
||||||
|
})
|
||||||
|
expect(ambiguous.status).toBe(404)
|
||||||
|
|
||||||
|
const directoryScoped = await fetch(
|
||||||
|
new URL(
|
||||||
|
`${PtyPaths.connectToken.replace(":ptyID", info.id)}?directory=${encodeURIComponent(tmp.path)}`,
|
||||||
|
listener.url,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: authorization(), "x-opencode-ticket": "1" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect(directoryScoped.status).toBe(200)
|
||||||
|
const mint = (await directoryScoped.json()) as { ticket: string }
|
||||||
|
const scopedWs = await openSocket(socketURL(listener, info.id, tmp.path, mint.ticket))
|
||||||
|
scopedWs.close(1000)
|
||||||
|
|
||||||
await expectSocketRejected(socketURL(listener, info.id, tmp.path, "not-a-ticket"))
|
await expectSocketRejected(socketURL(listener, info.id, tmp.path, "not-a-ticket"))
|
||||||
|
|
||||||
const reusable = await connectTicket(listener, info.id, tmp.path)
|
const reusable = await connectTicket(listener, info.id, tmp.path)
|
||||||
@@ -358,51 +381,8 @@ describe("HttpApi Server.listen", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Regression for #25698 (Ope): the app's SDK call to
|
|
||||||
// `client.pty.connectToken({ ptyID })` originally omitted `directory`, so
|
|
||||||
// the server resolved the PTY in its own cwd context — where the project
|
|
||||||
// PTY isn't registered — and returned 404. The fix is to always pass
|
|
||||||
// `directory` from the app side; this test locks in two contracts:
|
|
||||||
// 1. Mint without directory cannot find a PTY registered in another dir.
|
|
||||||
// 2. Mint with the project directory succeeds; the resulting ticket
|
|
||||||
// consumes cleanly when the WS upgrade carries the same directory.
|
|
||||||
testPty("PTY connect token requires matching directory across mint and connect", async () => {
|
|
||||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
|
||||||
const listener = await startListener()
|
|
||||||
try {
|
|
||||||
const info = await createCat(listener, tmp.path)
|
|
||||||
|
|
||||||
// Mint without directory — server uses its own cwd, can't find the PTY.
|
|
||||||
const ambiguous = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", info.id), listener.url), {
|
|
||||||
method: "POST",
|
|
||||||
headers: { authorization: authorization(), "x-opencode-ticket": "1" },
|
|
||||||
})
|
|
||||||
expect(ambiguous.status).toBe(404)
|
|
||||||
|
|
||||||
// Mint with the project directory — succeeds, ticket binds to that scope.
|
|
||||||
const scoped = await fetch(
|
|
||||||
new URL(
|
|
||||||
`${PtyPaths.connectToken.replace(":ptyID", info.id)}?directory=${encodeURIComponent(tmp.path)}`,
|
|
||||||
listener.url,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { authorization: authorization(), "x-opencode-ticket": "1" },
|
|
||||||
},
|
|
||||||
)
|
|
||||||
expect(scoped.status).toBe(200)
|
|
||||||
const mint = (await scoped.json()) as { ticket: string }
|
|
||||||
|
|
||||||
// Same directory on the WS upgrade → consume succeeds.
|
|
||||||
const ws = await openSocket(socketURL(listener, info.id, tmp.path, mint.ticket))
|
|
||||||
ws.close(1000)
|
|
||||||
} finally {
|
|
||||||
await stop(listener, "timed out cleaning up directory-scope listener").catch(() => undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => {
|
testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => {
|
||||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||||
const listener = await startNoAuthListener()
|
const listener = await startNoAuthListener()
|
||||||
try {
|
try {
|
||||||
const info = await createCat(listener, tmp.path)
|
const info = await createCat(listener, tmp.path)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Server } from "../../src/server/server"
|
|||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { resetDatabase } from "../fixture/db"
|
import { resetDatabase } from "../fixture/db"
|
||||||
import { TestInstance } from "../fixture/fixture"
|
import { TestInstance } from "../fixture/fixture"
|
||||||
|
import { markPluginDependenciesReady } from "../fixture/plugin"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
void Log.init({ print: false })
|
void Log.init({ print: false })
|
||||||
@@ -118,6 +119,7 @@ function requestCallback(input: {
|
|||||||
function writeProviderAuthPlugin(dir: string) {
|
function writeProviderAuthPlugin(dir: string) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* AppFileSystem.Service
|
||||||
|
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
|
||||||
|
|
||||||
yield* fs.writeWithDirs(
|
yield* fs.writeWithDirs(
|
||||||
path.join(dir, ".opencode", "plugin", "provider-oauth-parity.ts"),
|
path.join(dir, ".opencode", "plugin", "provider-oauth-parity.ts"),
|
||||||
@@ -152,6 +154,7 @@ function writeProviderAuthPlugin(dir: string) {
|
|||||||
function writeProviderAuthValidationPlugin(dir: string) {
|
function writeProviderAuthValidationPlugin(dir: string) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* AppFileSystem.Service
|
||||||
|
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
|
||||||
|
|
||||||
yield* fs.writeWithDirs(
|
yield* fs.writeWithDirs(
|
||||||
path.join(dir, ".opencode", "plugin", "provider-oauth-validation.ts"),
|
path.join(dir, ".opencode", "plugin", "provider-oauth-validation.ts"),
|
||||||
@@ -193,6 +196,7 @@ function writeProviderAuthValidationPlugin(dir: string) {
|
|||||||
function writeFunctionOptionsPlugin(dir: string) {
|
function writeFunctionOptionsPlugin(dir: string) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* AppFileSystem.Service
|
||||||
|
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
|
||||||
|
|
||||||
yield* fs.writeWithDirs(
|
yield* fs.writeWithDirs(
|
||||||
path.join(dir, ".opencode", "plugin", "provider-function-options.ts"),
|
path.join(dir, ".opencode", "plugin", "provider-function-options.ts"),
|
||||||
@@ -224,6 +228,7 @@ function writeFunctionOptionsPlugin(dir: string) {
|
|||||||
function writeProviderModelsMutationPlugin(dir: string) {
|
function writeProviderModelsMutationPlugin(dir: string) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* AppFileSystem.Service
|
||||||
|
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
|
||||||
|
|
||||||
yield* fs.writeWithDirs(
|
yield* fs.writeWithDirs(
|
||||||
path.join(dir, ".opencode", "plugin", "provider-models-mutation.ts"),
|
path.join(dir, ".opencode", "plugin", "provider-models-mutation.ts"),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect } from "bun:test"
|
import { afterEach, describe, expect } from "bun:test"
|
||||||
import { ConfigProvider, Effect, Layer } from "effect"
|
import { ConfigProvider, Deferred, Effect, Layer } from "effect"
|
||||||
import type * as Scope from "effect/Scope"
|
import type * as Scope from "effect/Scope"
|
||||||
import { HttpRouter } from "effect/unstable/http"
|
import { HttpRouter } from "effect/unstable/http"
|
||||||
import { ChildProcessSpawner } from "effect/unstable/process"
|
import { ChildProcessSpawner } from "effect/unstable/process"
|
||||||
@@ -22,7 +22,8 @@ import { TestLLMServer } from "../lib/llm-server"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { resetDatabase } from "../fixture/db"
|
import { resetDatabase } from "../fixture/db"
|
||||||
import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { awaitWithTimeout, testEffect } from "../lib/effect"
|
||||||
|
import { testProviderConfig } from "../lib/test-provider"
|
||||||
|
|
||||||
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
|
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
@@ -99,39 +100,6 @@ function authorization(username: string, password: string) {
|
|||||||
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function providerConfig(url: string) {
|
|
||||||
return {
|
|
||||||
formatter: false,
|
|
||||||
lsp: false,
|
|
||||||
provider: {
|
|
||||||
test: {
|
|
||||||
name: "Test",
|
|
||||||
id: "test",
|
|
||||||
env: [],
|
|
||||||
npm: "@ai-sdk/openai-compatible",
|
|
||||||
models: {
|
|
||||||
"test-model": {
|
|
||||||
id: "test-model",
|
|
||||||
name: "Test Model",
|
|
||||||
attachment: false,
|
|
||||||
reasoning: false,
|
|
||||||
temperature: false,
|
|
||||||
tool_call: true,
|
|
||||||
release_date: "2025-01-01",
|
|
||||||
limit: { context: 100000, output: 10000 },
|
|
||||||
cost: { input: 0, output: 0 },
|
|
||||||
options: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
apiKey: "test-key",
|
|
||||||
baseURL: url,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function call<T>(request: () => Promise<T>) {
|
function call<T>(request: () => Promise<T>) {
|
||||||
return Effect.promise(request)
|
return Effect.promise(request)
|
||||||
}
|
}
|
||||||
@@ -265,7 +233,7 @@ function withProject<A, E, E2 = never>(
|
|||||||
) {
|
) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const directory = yield* tmpdirScoped({
|
const directory = yield* tmpdirScoped({
|
||||||
git: options.git ?? true,
|
git: options.git ?? false,
|
||||||
config: { formatter: false, lsp: false, ...options.config },
|
config: { formatter: false, lsp: false, ...options.config },
|
||||||
})
|
})
|
||||||
yield* options.setup?.(directory) ?? Effect.void
|
yield* options.setup?.(directory) ?? Effect.void
|
||||||
@@ -283,7 +251,7 @@ function withStandardProject<A, E>(
|
|||||||
function withFakeLlm<A, E>(serverPath: ServerPath, run: (input: LlmProjectFixture) => Effect.Effect<A, E, TestScope>) {
|
function withFakeLlm<A, E>(serverPath: ServerPath, run: (input: LlmProjectFixture) => Effect.Effect<A, E, TestScope>) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const llm = yield* TestLLMServer
|
const llm = yield* TestLLMServer
|
||||||
return yield* withProject(serverPath, { config: providerConfig(llm.url) }, (input) => run({ ...input, llm }))
|
return yield* withProject(serverPath, { config: testProviderConfig(llm.url) }, (input) => run({ ...input, llm }))
|
||||||
}).pipe(Effect.provide(TestLLMServer.layer))
|
}).pipe(Effect.provide(TestLLMServer.layer))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,7 +265,7 @@ function withFakeLlmProject<A, E>(
|
|||||||
return yield* withProject(
|
return yield* withProject(
|
||||||
serverPath,
|
serverPath,
|
||||||
{
|
{
|
||||||
config: providerConfig(llm.url),
|
config: testProviderConfig(llm.url),
|
||||||
setup: options.setup,
|
setup: options.setup,
|
||||||
},
|
},
|
||||||
(input) => run({ ...input, llm }),
|
(input) => run({ ...input, llm }),
|
||||||
@@ -516,7 +484,7 @@ describe("HttpApi SDK", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
serverPathParity("matches generated SDK instance read routes", (serverPath) =>
|
serverPathParity("matches generated SDK instance read routes", (serverPath) =>
|
||||||
withStandardProject(serverPath, ({ sdk, directory }) =>
|
withProject(serverPath, { git: true, setup: writeStandardFiles }, ({ sdk, directory }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const project = yield* capture(() => sdk.project.current())
|
const project = yield* capture(() => sdk.project.current())
|
||||||
const projects = yield* capture(() => sdk.project.list())
|
const projects = yield* capture(() => sdk.project.list())
|
||||||
@@ -561,6 +529,7 @@ describe("HttpApi SDK", () => {
|
|||||||
foundFile: JSON.stringify(findFiles.data).includes("hello.txt"),
|
foundFile: JSON.stringify(findFiles.data).includes("hello.txt"),
|
||||||
foundText: JSON.stringify(findText.data ?? null).includes("sdk-parity"),
|
foundText: JSON.stringify(findText.data ?? null).includes("sdk-parity"),
|
||||||
listedFile: JSON.stringify(files.data).includes("hello.txt"),
|
listedFile: JSON.stringify(files.data).includes("hello.txt"),
|
||||||
|
vcs: { hasBranch: typeof record(vcs.data).branch === "string" },
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -671,6 +640,70 @@ describe("HttpApi SDK", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Regression: SyncEvent must publish on the same ProjectBus the /event handler
|
||||||
|
// subscribes to, AND the /event stream must forward handler ALS/context into the
|
||||||
|
// body-pump fiber. Drives the full SDK → /event → Session.updatePart → sync.run →
|
||||||
|
// bus.publish → SDK subscriber path. Goes red if either the publisher uses a
|
||||||
|
// different bus instance (Bug 2 / pre-#27825) or the stream loses context (Bug 1 /
|
||||||
|
// pre-#27425).
|
||||||
|
serverPathParity("streams sync-backed part updates to /event subscribers", (serverPath) =>
|
||||||
|
withStandardProject(serverPath, ({ sdk, directory }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* capture(() => sdk.session.create({ title: "sync-backed part event" }))
|
||||||
|
const sessionID = String(record(session.data).id)
|
||||||
|
const seeded = yield* seedMessage(directory, sessionID)
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
yield* Effect.addFinalizer(() => Effect.sync(() => controller.abort()))
|
||||||
|
const events = yield* call(() => sdk.event.subscribe(undefined, { signal: controller.signal }))
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
call(async () => void (await events.stream.return?.(undefined))).pipe(Effect.ignore),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ready = yield* Deferred.make<void>()
|
||||||
|
const received = yield* Deferred.make<unknown>()
|
||||||
|
|
||||||
|
yield* call(async () => {
|
||||||
|
for await (const event of events.stream) {
|
||||||
|
const payload = record(event).payload ?? event
|
||||||
|
const type = record(payload).type
|
||||||
|
if (type === "server.connected") {
|
||||||
|
Deferred.doneUnsafe(ready, Effect.void)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (type === MessageV2.Event.PartUpdated.type) {
|
||||||
|
Deferred.doneUnsafe(received, Effect.succeed(payload))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
|
yield* awaitWithTimeout(Deferred.await(ready), "timed out waiting for /event server.connected", "2 seconds")
|
||||||
|
|
||||||
|
const updated = yield* capture(() =>
|
||||||
|
sdk.part.update({
|
||||||
|
sessionID,
|
||||||
|
messageID: seeded.message.id,
|
||||||
|
partID: seeded.part.id,
|
||||||
|
part: { ...seeded.part, text: "updated via sync" } as NonNullable<
|
||||||
|
Parameters<Sdk["part"]["update"]>[0]["part"]
|
||||||
|
>,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(updated.status).toBe(200)
|
||||||
|
|
||||||
|
const event = yield* awaitWithTimeout(
|
||||||
|
Deferred.await(received),
|
||||||
|
"timed out waiting for message.part.updated bus payload over /event",
|
||||||
|
"5 seconds",
|
||||||
|
)
|
||||||
|
const properties = record(record(event).properties)
|
||||||
|
expect(record(properties.part)).toMatchObject({ id: seeded.part.id, type: "text" })
|
||||||
|
return { type: record(event).type, partType: record(properties.part).type }
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
serverPathParity("matches generated SDK prompt no-reply routes", (serverPath) =>
|
serverPathParity("matches generated SDK prompt no-reply routes", (serverPath) =>
|
||||||
withStandardProject(serverPath, ({ sdk }) =>
|
withStandardProject(serverPath, ({ sdk }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -823,7 +856,7 @@ describe("HttpApi SDK", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
serverPathParity("matches generated SDK project git initialization", (serverPath) =>
|
serverPathParity("matches generated SDK project git initialization", (serverPath) =>
|
||||||
withProject(serverPath, { git: false }, ({ sdk, directory }) =>
|
withProject(serverPath, {}, ({ sdk, directory }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const before = yield* capture(() => sdk.project.current())
|
const before = yield* capture(() => sdk.project.current())
|
||||||
const init = yield* capture(() => sdk.project.initGit())
|
const init = yield* capture(() => sdk.project.initGit())
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { TestConfig } from "../fixture/config"
|
|||||||
import { SyncEvent } from "@/sync"
|
import { SyncEvent } from "@/sync"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||||
|
import { LLMEvent, Usage } from "@opencode-ai/llm"
|
||||||
|
|
||||||
void Log.init({ print: false })
|
void Log.init({ print: false })
|
||||||
|
|
||||||
@@ -47,6 +48,10 @@ const ref = {
|
|||||||
modelID: ModelID.make("test-model"),
|
modelID: ModelID.make("test-model"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const usage = (input: ConstructorParameters<typeof Usage>[0]) => new Usage(input)
|
||||||
|
|
||||||
|
const basicUsage = () => usage({ inputTokens: 1, outputTokens: 1, totalTokens: 2 })
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.restore()
|
mock.restore()
|
||||||
})
|
})
|
||||||
@@ -296,11 +301,11 @@ function readCompactionPart(sessionID: SessionID) {
|
|||||||
|
|
||||||
function llm() {
|
function llm() {
|
||||||
const queue: Array<
|
const queue: Array<
|
||||||
Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)
|
Stream.Stream<LLMEvent, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown>)
|
||||||
> = []
|
> = []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
push(stream: Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)) {
|
push(stream: Stream.Stream<LLMEvent, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown>)) {
|
||||||
queue.push(stream)
|
queue.push(stream)
|
||||||
},
|
},
|
||||||
layer: Layer.succeed(
|
layer: Layer.succeed(
|
||||||
@@ -319,54 +324,22 @@ function llm() {
|
|||||||
function reply(
|
function reply(
|
||||||
text: string,
|
text: string,
|
||||||
capture?: (input: LLM.StreamInput) => void,
|
capture?: (input: LLM.StreamInput) => void,
|
||||||
): (input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown> {
|
): (input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown> {
|
||||||
return (input) => {
|
return (input) => {
|
||||||
capture?.(input)
|
capture?.(input)
|
||||||
return Stream.make(
|
return Stream.make(
|
||||||
{ type: "start" } satisfies LLM.Event,
|
LLMEvent.textStart({ id: "txt-0" }),
|
||||||
{ type: "text-start", id: "txt-0" } satisfies LLM.Event,
|
LLMEvent.textDelta({ id: "txt-0", text }),
|
||||||
{ type: "text-delta", id: "txt-0", delta: text, text } as LLM.Event,
|
LLMEvent.textEnd({ id: "txt-0" }),
|
||||||
{ type: "text-end", id: "txt-0" } satisfies LLM.Event,
|
LLMEvent.stepFinish({
|
||||||
{
|
index: 0,
|
||||||
type: "finish-step",
|
reason: "stop",
|
||||||
finishReason: "stop",
|
usage: basicUsage(),
|
||||||
rawFinishReason: "stop",
|
}),
|
||||||
response: { id: "res", modelId: "test-model", timestamp: new Date() },
|
LLMEvent.finish({
|
||||||
providerMetadata: undefined,
|
reason: "stop",
|
||||||
usage: {
|
usage: basicUsage(),
|
||||||
inputTokens: 1,
|
}),
|
||||||
outputTokens: 1,
|
|
||||||
totalTokens: 2,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: undefined,
|
|
||||||
cacheReadTokens: undefined,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies LLM.Event,
|
|
||||||
{
|
|
||||||
type: "finish",
|
|
||||||
finishReason: "stop",
|
|
||||||
rawFinishReason: "stop",
|
|
||||||
totalUsage: {
|
|
||||||
inputTokens: 1,
|
|
||||||
outputTokens: 1,
|
|
||||||
totalTokens: 2,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: undefined,
|
|
||||||
cacheReadTokens: undefined,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies LLM.Event,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1204,7 +1177,7 @@ describe("session.compaction.process", () => {
|
|||||||
Stream.fromAsyncIterable(
|
Stream.fromAsyncIterable(
|
||||||
{
|
{
|
||||||
async *[Symbol.asyncIterator]() {
|
async *[Symbol.asyncIterator]() {
|
||||||
yield { type: "start" } as LLM.Event
|
yield LLMEvent.stepStart({ index: 0 })
|
||||||
throw new APICallError({
|
throw new APICallError({
|
||||||
message: "boom",
|
message: "boom",
|
||||||
url: "https://example.com/v1/chat/completions",
|
url: "https://example.com/v1/chat/completions",
|
||||||
@@ -1290,55 +1263,62 @@ describe("session.compaction.process", () => {
|
|||||||
{ git: true },
|
{ git: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
itCompaction.instance(
|
||||||
|
"silently drops reasoning-delta arriving without prior reasoning-start",
|
||||||
|
() => {
|
||||||
|
// Regression: PR initially auto-created a reasoning Part for orphan deltas (no preceding
|
||||||
|
// reasoning-start). Reverted to match dev — drop silently. Pinned here so any future
|
||||||
|
// change to processor.ts reasoning-delta handling triggers this test.
|
||||||
|
const stub = llm()
|
||||||
|
stub.push(
|
||||||
|
Stream.make(
|
||||||
|
LLMEvent.reasoningDelta({ id: "orphan-1", text: "stray reasoning" }),
|
||||||
|
LLMEvent.textStart({ id: "txt-0" }),
|
||||||
|
LLMEvent.textDelta({ id: "txt-0", text: "summary" }),
|
||||||
|
LLMEvent.textEnd({ id: "txt-0" }),
|
||||||
|
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: basicUsage() }),
|
||||||
|
LLMEvent.finish({ reason: "stop", usage: basicUsage() }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const ssn = yield* SessionNs.Service
|
||||||
|
const session = yield* ssn.create({})
|
||||||
|
const msg = yield* createUserMessage(session.id, "hello")
|
||||||
|
const msgs = yield* ssn.messages({ sessionID: session.id })
|
||||||
|
yield* SessionCompaction.use.process({
|
||||||
|
parentID: msg.id,
|
||||||
|
messages: msgs,
|
||||||
|
sessionID: session.id,
|
||||||
|
auto: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const summary = (yield* ssn.messages({ sessionID: session.id })).find(
|
||||||
|
(item) => item.info.role === "assistant" && item.info.summary,
|
||||||
|
)
|
||||||
|
expect(summary?.parts.some((part) => part.type === "reasoning")).toBe(false)
|
||||||
|
// Sanity: the text part still got through.
|
||||||
|
expect(summary?.parts.some((part) => part.type === "text" && part.text === "summary")).toBe(true)
|
||||||
|
}).pipe(withCompaction({ llm: stub.layer }))
|
||||||
|
},
|
||||||
|
{ git: true },
|
||||||
|
)
|
||||||
|
|
||||||
itCompaction.instance(
|
itCompaction.instance(
|
||||||
"does not allow tool calls while generating the summary",
|
"does not allow tool calls while generating the summary",
|
||||||
() => {
|
() => {
|
||||||
const stub = llm()
|
const stub = llm()
|
||||||
stub.push(
|
stub.push(
|
||||||
Stream.make(
|
Stream.make(
|
||||||
{ type: "start" } satisfies LLM.Event,
|
LLMEvent.toolCall({ id: "call-1", name: "_noop", input: {} }),
|
||||||
{ type: "tool-input-start", id: "call-1", toolName: "_noop" } satisfies LLM.Event,
|
LLMEvent.stepFinish({
|
||||||
{ type: "tool-call", toolCallId: "call-1", toolName: "_noop", input: {} } satisfies LLM.Event,
|
index: 0,
|
||||||
{
|
reason: "tool-calls",
|
||||||
type: "finish-step",
|
usage: basicUsage(),
|
||||||
finishReason: "tool-calls",
|
}),
|
||||||
rawFinishReason: "tool_calls",
|
LLMEvent.finish({
|
||||||
response: { id: "res", modelId: "test-model", timestamp: new Date() },
|
reason: "tool-calls",
|
||||||
providerMetadata: undefined,
|
usage: basicUsage(),
|
||||||
usage: {
|
}),
|
||||||
inputTokens: 1,
|
|
||||||
outputTokens: 1,
|
|
||||||
totalTokens: 2,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: undefined,
|
|
||||||
cacheReadTokens: undefined,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies LLM.Event,
|
|
||||||
{
|
|
||||||
type: "finish",
|
|
||||||
finishReason: "tool-calls",
|
|
||||||
rawFinishReason: "tool_calls",
|
|
||||||
totalUsage: {
|
|
||||||
inputTokens: 1,
|
|
||||||
outputTokens: 1,
|
|
||||||
totalTokens: 2,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: undefined,
|
|
||||||
cacheReadTokens: undefined,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies LLM.Event,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
@@ -1544,20 +1524,7 @@ describe("SessionNs.getUsage", () => {
|
|||||||
const model = createModel({ context: 100_000, output: 32_000 })
|
const model = createModel({ context: 100_000, output: 32_000 })
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage: {
|
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500 }),
|
||||||
inputTokens: 1000,
|
|
||||||
outputTokens: 500,
|
|
||||||
totalTokens: 1500,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: undefined,
|
|
||||||
cacheReadTokens: undefined,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.tokens.input).toBe(1000)
|
expect(result.tokens.input).toBe(1000)
|
||||||
@@ -1571,20 +1538,7 @@ describe("SessionNs.getUsage", () => {
|
|||||||
const model = createModel({ context: 100_000, output: 32_000 })
|
const model = createModel({ context: 100_000, output: 32_000 })
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage: {
|
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
|
||||||
inputTokens: 1000,
|
|
||||||
outputTokens: 500,
|
|
||||||
totalTokens: 1500,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: 800,
|
|
||||||
cacheReadTokens: 200,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.tokens.input).toBe(800)
|
expect(result.tokens.input).toBe(800)
|
||||||
@@ -1595,20 +1549,7 @@ describe("SessionNs.getUsage", () => {
|
|||||||
const model = createModel({ context: 100_000, output: 32_000 })
|
const model = createModel({ context: 100_000, output: 32_000 })
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage: {
|
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500 }),
|
||||||
inputTokens: 1000,
|
|
||||||
outputTokens: 500,
|
|
||||||
totalTokens: 1500,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: undefined,
|
|
||||||
cacheReadTokens: undefined,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: {
|
metadata: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
cacheCreationInputTokens: 300,
|
cacheCreationInputTokens: 300,
|
||||||
@@ -1624,20 +1565,7 @@ describe("SessionNs.getUsage", () => {
|
|||||||
// AI SDK v6 normalizes inputTokens to include cached tokens for all providers
|
// AI SDK v6 normalizes inputTokens to include cached tokens for all providers
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage: {
|
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
|
||||||
inputTokens: 1000,
|
|
||||||
outputTokens: 500,
|
|
||||||
totalTokens: 1500,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: 800,
|
|
||||||
cacheReadTokens: 200,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: {
|
metadata: {
|
||||||
anthropic: {},
|
anthropic: {},
|
||||||
},
|
},
|
||||||
@@ -1651,20 +1579,7 @@ describe("SessionNs.getUsage", () => {
|
|||||||
const model = createModel({ context: 100_000, output: 32_000 })
|
const model = createModel({ context: 100_000, output: 32_000 })
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage: {
|
usage: usage({ inputTokens: 1000, outputTokens: 500, reasoningTokens: 100, totalTokens: 1500 }),
|
||||||
inputTokens: 1000,
|
|
||||||
outputTokens: 500,
|
|
||||||
totalTokens: 1500,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: undefined,
|
|
||||||
cacheReadTokens: undefined,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: 400,
|
|
||||||
reasoningTokens: 100,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.tokens.input).toBe(1000)
|
expect(result.tokens.input).toBe(1000)
|
||||||
@@ -1685,20 +1600,7 @@ describe("SessionNs.getUsage", () => {
|
|||||||
})
|
})
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage: {
|
usage: usage({ inputTokens: 0, outputTokens: 1_000_000, reasoningTokens: 250_000, totalTokens: 1_000_000 }),
|
||||||
inputTokens: 0,
|
|
||||||
outputTokens: 1_000_000,
|
|
||||||
totalTokens: 1_000_000,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: undefined,
|
|
||||||
cacheReadTokens: undefined,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: 750_000,
|
|
||||||
reasoningTokens: 250_000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.tokens.output).toBe(750_000)
|
expect(result.tokens.output).toBe(750_000)
|
||||||
@@ -1710,20 +1612,7 @@ describe("SessionNs.getUsage", () => {
|
|||||||
const model = createModel({ context: 100_000, output: 32_000 })
|
const model = createModel({ context: 100_000, output: 32_000 })
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage: {
|
usage: usage({ inputTokens: 0, outputTokens: 0, totalTokens: 0 }),
|
||||||
inputTokens: 0,
|
|
||||||
outputTokens: 0,
|
|
||||||
totalTokens: 0,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: undefined,
|
|
||||||
cacheReadTokens: undefined,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.tokens.input).toBe(0)
|
expect(result.tokens.input).toBe(0)
|
||||||
@@ -1746,20 +1635,7 @@ describe("SessionNs.getUsage", () => {
|
|||||||
})
|
})
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage: {
|
usage: usage({ inputTokens: 1_000_000, outputTokens: 100_000, totalTokens: 1_100_000 }),
|
||||||
inputTokens: 1_000_000,
|
|
||||||
outputTokens: 100_000,
|
|
||||||
totalTokens: 1_100_000,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: undefined,
|
|
||||||
cacheReadTokens: undefined,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.cost).toBe(3 + 1.5)
|
expect(result.cost).toBe(3 + 1.5)
|
||||||
@@ -1796,20 +1672,12 @@ describe("SessionNs.getUsage", () => {
|
|||||||
})
|
})
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage: {
|
usage: usage({
|
||||||
inputTokens: 650_000,
|
inputTokens: 650_000,
|
||||||
outputTokens: 100_000,
|
outputTokens: 100_000,
|
||||||
totalTokens: 750_000,
|
totalTokens: 750_000,
|
||||||
inputTokenDetails: {
|
cacheReadInputTokens: 100_000,
|
||||||
noCacheTokens: undefined,
|
}),
|
||||||
cacheReadTokens: 100_000,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.tokens.input).toBe(550_000)
|
expect(result.tokens.input).toBe(550_000)
|
||||||
@@ -1841,20 +1709,7 @@ describe("SessionNs.getUsage", () => {
|
|||||||
})
|
})
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage: {
|
usage: usage({ inputTokens: 300_000, outputTokens: 100_000, totalTokens: 400_000 }),
|
||||||
inputTokens: 300_000,
|
|
||||||
outputTokens: 100_000,
|
|
||||||
totalTokens: 400_000,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: undefined,
|
|
||||||
cacheReadTokens: undefined,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.cost).toBe(0.9 + 0.4)
|
expect(result.cost).toBe(0.9 + 0.4)
|
||||||
@@ -1865,24 +1720,16 @@ describe("SessionNs.getUsage", () => {
|
|||||||
(npm) => {
|
(npm) => {
|
||||||
const model = createModel({ context: 100_000, output: 32_000, npm })
|
const model = createModel({ context: 100_000, output: 32_000, npm })
|
||||||
// AI SDK v6: inputTokens includes cached tokens for all providers
|
// AI SDK v6: inputTokens includes cached tokens for all providers
|
||||||
const usage = {
|
const item = usage({
|
||||||
inputTokens: 1000,
|
inputTokens: 1000,
|
||||||
outputTokens: 500,
|
outputTokens: 500,
|
||||||
totalTokens: 1500,
|
totalTokens: 1500,
|
||||||
inputTokenDetails: {
|
cacheReadInputTokens: 200,
|
||||||
noCacheTokens: 800,
|
})
|
||||||
cacheReadTokens: 200,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if (npm === "@ai-sdk/amazon-bedrock") {
|
if (npm === "@ai-sdk/amazon-bedrock") {
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage,
|
usage: item,
|
||||||
metadata: {
|
metadata: {
|
||||||
bedrock: {
|
bedrock: {
|
||||||
usage: {
|
usage: {
|
||||||
@@ -1903,7 +1750,7 @@ describe("SessionNs.getUsage", () => {
|
|||||||
|
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage,
|
usage: item,
|
||||||
metadata: {
|
metadata: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
cacheCreationInputTokens: 300,
|
cacheCreationInputTokens: 300,
|
||||||
@@ -1924,20 +1771,7 @@ describe("SessionNs.getUsage", () => {
|
|||||||
const model = createModel({ context: 100_000, output: 32_000, npm: "@ai-sdk/google-vertex/anthropic" })
|
const model = createModel({ context: 100_000, output: 32_000, npm: "@ai-sdk/google-vertex/anthropic" })
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage: {
|
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
|
||||||
inputTokens: 1000,
|
|
||||||
outputTokens: 500,
|
|
||||||
totalTokens: 1500,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: 800,
|
|
||||||
cacheReadTokens: 200,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: {
|
metadata: {
|
||||||
vertex: {
|
vertex: {
|
||||||
cacheCreationInputTokens: 300,
|
cacheCreationInputTokens: 300,
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
|
import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder"
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { tool } from "ai"
|
||||||
|
import { Effect, Layer, Stream } from "effect"
|
||||||
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
|
import path from "node:path"
|
||||||
|
import z from "zod"
|
||||||
|
import { Auth } from "@/auth"
|
||||||
|
import { Config } from "@/config/config"
|
||||||
|
import { Plugin } from "@/plugin"
|
||||||
|
import { Provider } from "@/provider/provider"
|
||||||
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
|
import { Filesystem } from "@/util/filesystem"
|
||||||
|
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
||||||
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
|
import type { Agent } from "../../src/agent/agent"
|
||||||
|
import { LLM } from "../../src/session/llm"
|
||||||
|
import { MessageV2 } from "../../src/session/message-v2"
|
||||||
|
import { MessageID, SessionID } from "../../src/session/schema"
|
||||||
|
import type { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||||
|
import { TestInstance } from "../fixture/fixture"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
|
const OPENAI_CASSETTE = "session/native-openai-tool-call"
|
||||||
|
const ZEN_CASSETTE = "session/native-zen-tool-call"
|
||||||
|
const FIXTURES_DIR = path.join(import.meta.dir, "../fixtures/recordings")
|
||||||
|
const OPENAI_API_KEY = process.env.OPENCODE_RECORD_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY
|
||||||
|
const CONSOLE_TOKEN = process.env.OPENCODE_RECORD_CONSOLE_TOKEN
|
||||||
|
const ZEN_ORG_ID = process.env.OPENCODE_RECORD_ZEN_ORG_ID
|
||||||
|
const ZEN_API_URL =
|
||||||
|
process.env.OPENCODE_RECORD_ZEN_API_URL ?? "https://console.opencode.ai/proxy/connections/fixture/v1"
|
||||||
|
|
||||||
|
const shouldRecord = process.env.RECORD === "true"
|
||||||
|
const canRunOpenAI = shouldRecord
|
||||||
|
? Boolean(OPENAI_API_KEY)
|
||||||
|
: HttpRecorder.hasCassetteSync(OPENAI_CASSETTE, { directory: FIXTURES_DIR })
|
||||||
|
const canRunZen = shouldRecord
|
||||||
|
? Boolean(CONSOLE_TOKEN && ZEN_ORG_ID)
|
||||||
|
: HttpRecorder.hasCassetteSync(ZEN_CASSETTE, { directory: FIXTURES_DIR })
|
||||||
|
|
||||||
|
async function loadFixture(providerID: string, modelID: string) {
|
||||||
|
const data = await Filesystem.readJson<Record<string, ModelsDev.Provider>>(
|
||||||
|
path.join(import.meta.dir, "../tool/fixtures/models-api.json"),
|
||||||
|
)
|
||||||
|
const provider = data[providerID]
|
||||||
|
if (!provider) throw new Error(`Missing provider in fixture: ${providerID}`)
|
||||||
|
const model = provider.models[modelID]
|
||||||
|
if (!model) throw new Error(`Missing model in fixture: ${modelID}`)
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAIConfig = (model: ModelsDev.Provider["models"][string]): Partial<Config.Info> => ({
|
||||||
|
enabled_providers: ["openai"],
|
||||||
|
provider: {
|
||||||
|
openai: {
|
||||||
|
name: "OpenAI",
|
||||||
|
env: ["OPENAI_API_KEY"],
|
||||||
|
npm: "@ai-sdk/openai",
|
||||||
|
api: "https://api.openai.com/v1",
|
||||||
|
models: {
|
||||||
|
[model.id]: JSON.parse(JSON.stringify(model)) as NonNullable<
|
||||||
|
NonNullable<Config.Info["provider"]>[string]["models"]
|
||||||
|
>[string],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
apiKey: OPENAI_API_KEY ?? "fixture-openai-key",
|
||||||
|
baseURL: "https://api.openai.com/v1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const zenConfig = (model: ModelsDev.Provider["models"][string]): Partial<Config.Info> => ({
|
||||||
|
enabled_providers: ["opencode"],
|
||||||
|
provider: {
|
||||||
|
opencode: {
|
||||||
|
name: "OpenCode Zen",
|
||||||
|
env: ["OPENCODE_CONSOLE_TOKEN"],
|
||||||
|
npm: "@ai-sdk/openai-compatible",
|
||||||
|
api: ZEN_API_URL,
|
||||||
|
models: {
|
||||||
|
[model.id]: JSON.parse(JSON.stringify(model)) as NonNullable<
|
||||||
|
NonNullable<Config.Info["provider"]>[string]["models"]
|
||||||
|
>[string],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
apiKey: CONSOLE_TOKEN ?? "fixture-console-token",
|
||||||
|
headers: {
|
||||||
|
"x-org-id": ZEN_ORG_ID ?? "fixture-org",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function recordedNativeLLMLayer(cassette: string, metadata: Record<string, unknown>) {
|
||||||
|
const cassetteService = HttpRecorder.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe(
|
||||||
|
Layer.provide(NodeFileSystem.layer),
|
||||||
|
)
|
||||||
|
// Only the HTTP client is recorded; RequestExecutor and the opencode LLM stack remain real.
|
||||||
|
const recorder = HttpRecorder.recordingLayer(cassette, {
|
||||||
|
mode: shouldRecord ? "record" : "replay",
|
||||||
|
metadata,
|
||||||
|
redactor: Redactor.compose(
|
||||||
|
Redactor.defaults({
|
||||||
|
url: {
|
||||||
|
transform: (url) => url.replace(/\/proxy\/connections\/[^/]+\/v1/, "/proxy/connections/{connection}/v1"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
response: (snapshot) => ({ ...snapshot, body: snapshot.body.replace(/wrk_[A-Z0-9]+/g, "wrk_redacted") }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}).pipe(Layer.provide(FetchHttpClient.layer))
|
||||||
|
const executor = RequestExecutor.layer.pipe(Layer.provide(recorder))
|
||||||
|
const client = LLMClient.layer.pipe(Layer.provide(executor))
|
||||||
|
|
||||||
|
const providerLayer = Provider.defaultLayer.pipe(
|
||||||
|
Layer.provide(Auth.defaultLayer),
|
||||||
|
Layer.provide(Config.defaultLayer),
|
||||||
|
Layer.provide(Plugin.defaultLayer),
|
||||||
|
)
|
||||||
|
const llmLayer = LLM.layer.pipe(
|
||||||
|
Layer.provide(Auth.defaultLayer),
|
||||||
|
Layer.provide(Config.defaultLayer),
|
||||||
|
Layer.provide(Provider.defaultLayer),
|
||||||
|
Layer.provide(Plugin.defaultLayer),
|
||||||
|
Layer.provide(client),
|
||||||
|
Layer.provide(cassetteService),
|
||||||
|
Layer.provide(RuntimeFlags.layer({ experimentalNativeLlm: true })),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Layer.mergeAll(providerLayer, llmLayer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAIIt = testEffect(
|
||||||
|
recordedNativeLLMLayer(OPENAI_CASSETTE, {
|
||||||
|
provider: "openai",
|
||||||
|
protocol: "openai-responses",
|
||||||
|
route: "openai-responses",
|
||||||
|
tags: ["opencode", "native", "tool-call"],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const zenIt = testEffect(
|
||||||
|
recordedNativeLLMLayer(ZEN_CASSETTE, {
|
||||||
|
provider: "opencode",
|
||||||
|
protocol: "openai-responses",
|
||||||
|
route: "openai-responses",
|
||||||
|
tags: ["opencode", "zen", "native", "tool-call"],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const recordedOpenAIInstance = canRunOpenAI ? openAIIt.instance : openAIIt.instance.skip
|
||||||
|
const recordedZenInstance = canRunZen ? zenIt.instance : zenIt.instance.skip
|
||||||
|
|
||||||
|
const writeConfig = (
|
||||||
|
directory: string,
|
||||||
|
model: ModelsDev.Provider["models"][string],
|
||||||
|
config: (model: ModelsDev.Provider["models"][string]) => Partial<Config.Info> = openAIConfig,
|
||||||
|
) =>
|
||||||
|
Effect.promise(() =>
|
||||||
|
Bun.write(
|
||||||
|
path.join(directory, "opencode.json"),
|
||||||
|
JSON.stringify({ $schema: "https://opencode.ai/config.json", ...config(model) }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const getModel = (providerID: ProviderID, modelID: ModelID) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const provider = yield* Provider.Service
|
||||||
|
return yield* provider.getModel(providerID, modelID)
|
||||||
|
})
|
||||||
|
|
||||||
|
const collect = (input: LLM.StreamInput) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const llm = yield* LLM.Service
|
||||||
|
return Array.from(yield* llm.stream(input).pipe(Stream.runCollect))
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("session.llm native recorded", () => {
|
||||||
|
recordedOpenAIInstance("uses real RequestExecutor with HTTP recorder for native OpenAI tools", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const test = yield* TestInstance
|
||||||
|
const model = yield* Effect.promise(() => loadFixture("openai", "gpt-4.1-mini"))
|
||||||
|
yield* writeConfig(test.directory, model)
|
||||||
|
|
||||||
|
const sessionID = SessionID.make("session-recorded-native-tool")
|
||||||
|
const agent = {
|
||||||
|
name: "test",
|
||||||
|
mode: "primary",
|
||||||
|
prompt: "Call tools exactly as instructed.",
|
||||||
|
options: {},
|
||||||
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||||
|
temperature: 0,
|
||||||
|
} satisfies Agent.Info
|
||||||
|
const resolved = yield* getModel(ProviderID.openai, ModelID.make(model.id))
|
||||||
|
let executed: unknown
|
||||||
|
|
||||||
|
const events = yield* collect({
|
||||||
|
user: {
|
||||||
|
id: MessageID.make("msg_user-recorded-native-tool"),
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: 0 },
|
||||||
|
agent: agent.name,
|
||||||
|
model: { providerID: ProviderID.make("openai"), modelID: ModelID.make(model.id) },
|
||||||
|
} satisfies MessageV2.User,
|
||||||
|
sessionID,
|
||||||
|
model: resolved,
|
||||||
|
agent,
|
||||||
|
system: ["You must call the lookup tool exactly once with query weather. Do not answer in text."],
|
||||||
|
messages: [{ role: "user", content: "Use lookup." }],
|
||||||
|
toolChoice: "required",
|
||||||
|
tools: {
|
||||||
|
lookup: tool({
|
||||||
|
description: "Lookup data.",
|
||||||
|
inputSchema: z.object({ query: z.string() }),
|
||||||
|
execute: async (args, options) => {
|
||||||
|
executed = { args, toolCallId: options.toolCallId }
|
||||||
|
return { output: "looked up" }
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(events.filter((event) => event.type === "step-finish")).toHaveLength(1)
|
||||||
|
expect(events.filter((event) => event.type === "finish")).toHaveLength(1)
|
||||||
|
expect(events.some((event) => event.type === "tool-result")).toBe(true)
|
||||||
|
expect(executed).toMatchObject({ args: { query: "weather" }, toolCallId: expect.any(String) })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
recordedZenInstance("uses console-managed Zen config with native OpenAI-compatible tools", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const test = yield* TestInstance
|
||||||
|
const model = yield* Effect.promise(() => loadFixture("opencode", "gpt-5.2-codex"))
|
||||||
|
yield* writeConfig(test.directory, model, zenConfig)
|
||||||
|
|
||||||
|
const sessionID = SessionID.make("session-recorded-native-zen-tool")
|
||||||
|
const agent = {
|
||||||
|
name: "test",
|
||||||
|
mode: "primary",
|
||||||
|
prompt: "Call tools exactly as instructed.",
|
||||||
|
options: {},
|
||||||
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||||
|
} satisfies Agent.Info
|
||||||
|
const resolved = yield* getModel(ProviderID.opencode, ModelID.make(model.id))
|
||||||
|
let executed: unknown
|
||||||
|
|
||||||
|
const events = yield* collect({
|
||||||
|
user: {
|
||||||
|
id: MessageID.make("msg_user-recorded-native-zen-tool"),
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: 0 },
|
||||||
|
agent: agent.name,
|
||||||
|
model: { providerID: ProviderID.opencode, modelID: ModelID.make(model.id) },
|
||||||
|
} satisfies MessageV2.User,
|
||||||
|
sessionID,
|
||||||
|
model: resolved,
|
||||||
|
agent,
|
||||||
|
system: ["You must call the lookup tool exactly once with query weather. Do not answer in text."],
|
||||||
|
messages: [{ role: "user", content: "Use lookup." }],
|
||||||
|
toolChoice: "required",
|
||||||
|
tools: {
|
||||||
|
lookup: tool({
|
||||||
|
description: "Lookup data.",
|
||||||
|
inputSchema: z.object({ query: z.string() }),
|
||||||
|
execute: async (args, options) => {
|
||||||
|
executed = { args, toolCallId: options.toolCallId }
|
||||||
|
return { output: "looked up" }
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(events.filter((event) => event.type === "step-finish")).toHaveLength(1)
|
||||||
|
expect(events.filter((event) => event.type === "finish")).toHaveLength(1)
|
||||||
|
expect(events.some((event) => event.type === "tool-result")).toBe(true)
|
||||||
|
expect(executed).toMatchObject({ args: { query: "weather" }, toolCallId: expect.any(String) })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { ToolFailure } from "@opencode-ai/llm"
|
||||||
|
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
||||||
|
import { jsonSchema, tool, type ModelMessage } from "ai"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { LLMNative } from "@/session/llm/native-request"
|
||||||
|
import { LLMNativeRuntime } from "@/session/llm/native-runtime"
|
||||||
|
import type { Provider } from "@/provider/provider"
|
||||||
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
|
|
||||||
|
const baseModel: Provider.Model = {
|
||||||
|
id: ModelID.make("gpt-5-mini"),
|
||||||
|
providerID: ProviderID.make("openai"),
|
||||||
|
api: {
|
||||||
|
id: "gpt-5-mini",
|
||||||
|
url: "https://api.openai.com/v1",
|
||||||
|
npm: "@ai-sdk/openai",
|
||||||
|
},
|
||||||
|
name: "GPT-5 Mini",
|
||||||
|
capabilities: {
|
||||||
|
temperature: true,
|
||||||
|
reasoning: true,
|
||||||
|
attachment: true,
|
||||||
|
toolcall: true,
|
||||||
|
input: {
|
||||||
|
text: true,
|
||||||
|
audio: false,
|
||||||
|
image: true,
|
||||||
|
video: false,
|
||||||
|
pdf: false,
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
text: true,
|
||||||
|
audio: false,
|
||||||
|
image: false,
|
||||||
|
video: false,
|
||||||
|
pdf: false,
|
||||||
|
},
|
||||||
|
interleaved: false,
|
||||||
|
},
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cache: {
|
||||||
|
read: 0,
|
||||||
|
write: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
context: 128_000,
|
||||||
|
input: 128_000,
|
||||||
|
output: 32_000,
|
||||||
|
},
|
||||||
|
status: "active",
|
||||||
|
options: {},
|
||||||
|
headers: {
|
||||||
|
"x-model": "model-header",
|
||||||
|
},
|
||||||
|
release_date: "2026-01-01",
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerInfo: Provider.Info = {
|
||||||
|
id: ProviderID.make("openai"),
|
||||||
|
name: "OpenAI",
|
||||||
|
source: "config",
|
||||||
|
env: ["OPENAI_API_KEY"],
|
||||||
|
options: { apiKey: "test-openai-key" },
|
||||||
|
models: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("session.llm-native.request", () => {
|
||||||
|
test("maps normalized stream inputs to a native LLM request", () => {
|
||||||
|
const messages: ModelMessage[] = [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: "system from messages",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "hello", providerOptions: { openai: { cacheControl: { type: "ephemeral" } } } },
|
||||||
|
{ type: "file", mediaType: "image/png", filename: "img.png", data: "data:image/png;base64,Zm9v" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "reasoning", text: "thinking", providerOptions: { openai: { encryptedContent: "secret" } } },
|
||||||
|
{ type: "text", text: "I'll run it" },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId: "call-1",
|
||||||
|
toolName: "bash",
|
||||||
|
input: { command: "ls" },
|
||||||
|
providerOptions: { openai: { itemId: "item-1" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "tool",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool-result",
|
||||||
|
toolCallId: "call-1",
|
||||||
|
toolName: "bash",
|
||||||
|
output: { type: "text", value: "ok" },
|
||||||
|
providerOptions: { openai: { outputId: "output-1" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const request = LLMNative.request({
|
||||||
|
model: baseModel,
|
||||||
|
system: ["agent system"],
|
||||||
|
messages,
|
||||||
|
tools: {
|
||||||
|
bash: tool({
|
||||||
|
description: "Run a shell command",
|
||||||
|
inputSchema: jsonSchema({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
command: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["command"],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
toolChoice: "required",
|
||||||
|
temperature: 0.2,
|
||||||
|
topP: 0.9,
|
||||||
|
topK: 40,
|
||||||
|
maxOutputTokens: 1024,
|
||||||
|
providerOptions: { openai: { store: false } },
|
||||||
|
headers: { "x-request": "request-header" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(request.model).toMatchObject({
|
||||||
|
id: "gpt-5-mini",
|
||||||
|
provider: "openai",
|
||||||
|
route: "openai-responses",
|
||||||
|
baseURL: "https://api.openai.com/v1",
|
||||||
|
headers: {
|
||||||
|
"x-model": "model-header",
|
||||||
|
"x-request": "request-header",
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
context: 128_000,
|
||||||
|
output: 32_000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(request.system).toEqual([
|
||||||
|
{ type: "text", text: "agent system" },
|
||||||
|
{ type: "text", text: "system from messages" },
|
||||||
|
])
|
||||||
|
expect(request.generation).toMatchObject({
|
||||||
|
temperature: 0.2,
|
||||||
|
topP: 0.9,
|
||||||
|
topK: 40,
|
||||||
|
maxTokens: 1024,
|
||||||
|
})
|
||||||
|
expect(request.providerOptions).toEqual({ openai: { store: false } })
|
||||||
|
expect(request.toolChoice).toMatchObject({ type: "required" })
|
||||||
|
expect(request.tools).toMatchObject([
|
||||||
|
{
|
||||||
|
name: "bash",
|
||||||
|
description: "Run a shell command",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
command: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["command"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(request.messages).toMatchObject([
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "hello", providerMetadata: { openai: { cacheControl: { type: "ephemeral" } } } },
|
||||||
|
{ type: "media", mediaType: "image/png", filename: "img.png", data: "data:image/png;base64,Zm9v" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "reasoning", text: "thinking", providerMetadata: { openai: { encryptedContent: "secret" } } },
|
||||||
|
{ type: "text", text: "I'll run it" },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
id: "call-1",
|
||||||
|
name: "bash",
|
||||||
|
input: { command: "ls" },
|
||||||
|
providerMetadata: { openai: { itemId: "item-1" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "tool",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool-result",
|
||||||
|
id: "call-1",
|
||||||
|
name: "bash",
|
||||||
|
result: { type: "text", value: "ok" },
|
||||||
|
providerMetadata: { openai: { outputId: "output-1" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("selects native routes from existing provider packages", () => {
|
||||||
|
expect(
|
||||||
|
LLMNative.model({ ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/anthropic" } }),
|
||||||
|
).toMatchObject({
|
||||||
|
route: "anthropic-messages",
|
||||||
|
baseURL: "https://api.anthropic.com/v1",
|
||||||
|
})
|
||||||
|
expect(LLMNative.model({ ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/google" } })).toMatchObject({
|
||||||
|
route: "gemini",
|
||||||
|
baseURL: "https://generativelanguage.googleapis.com/v1beta",
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
LLMNative.model({ ...baseModel, api: { ...baseModel.api, npm: "@ai-sdk/openai-compatible" } }),
|
||||||
|
).toMatchObject({
|
||||||
|
route: "openai-compatible-chat",
|
||||||
|
baseURL: "https://api.openai.com/v1",
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
LLMNative.model({ ...baseModel, api: { ...baseModel.api, url: "", npm: "@openrouter/ai-sdk-provider" } }),
|
||||||
|
).toMatchObject({
|
||||||
|
route: "openrouter",
|
||||||
|
baseURL: "https://openrouter.ai/api/v1",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("fails fast for unsupported provider packages", () => {
|
||||||
|
expect(() =>
|
||||||
|
LLMNative.request({
|
||||||
|
model: { ...baseModel, api: { ...baseModel.api, npm: "unknown-provider" } },
|
||||||
|
messages: [],
|
||||||
|
}),
|
||||||
|
).toThrow("Native LLM request adapter does not support provider package unknown-provider")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("only enables native runtime for supported OpenAI API-key models", () => {
|
||||||
|
expect(LLMNativeRuntime.status({ model: baseModel, provider: providerInfo, auth: undefined })).toMatchObject({
|
||||||
|
type: "supported",
|
||||||
|
apiKey: "test-openai-key",
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
LLMNativeRuntime.status({
|
||||||
|
model: { ...baseModel, providerID: ProviderID.make("opencode") },
|
||||||
|
provider: { ...providerInfo, id: ProviderID.make("opencode") },
|
||||||
|
auth: undefined,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
type: "supported",
|
||||||
|
apiKey: "test-openai-key",
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
LLMNativeRuntime.status({
|
||||||
|
model: { ...baseModel, providerID: ProviderID.make("anthropic") },
|
||||||
|
provider: { ...providerInfo, id: ProviderID.make("anthropic") },
|
||||||
|
auth: undefined,
|
||||||
|
}),
|
||||||
|
).toEqual({ type: "unsupported", reason: "provider is not openai or opencode" })
|
||||||
|
expect(
|
||||||
|
LLMNativeRuntime.status({
|
||||||
|
model: baseModel,
|
||||||
|
provider: providerInfo,
|
||||||
|
auth: { type: "oauth", refresh: "refresh", access: "access", expires: 1 },
|
||||||
|
}),
|
||||||
|
).toEqual({ type: "unsupported", reason: "OAuth auth is not supported" })
|
||||||
|
|
||||||
|
expect(
|
||||||
|
LLMNativeRuntime.status({
|
||||||
|
model: { ...baseModel, api: { ...baseModel.api, npm: "@ai-sdk/anthropic" } },
|
||||||
|
provider: providerInfo,
|
||||||
|
auth: undefined,
|
||||||
|
}),
|
||||||
|
).toEqual({ type: "unsupported", reason: "provider package is not OpenAI" })
|
||||||
|
|
||||||
|
expect(
|
||||||
|
LLMNativeRuntime.status({
|
||||||
|
model: baseModel,
|
||||||
|
provider: { ...providerInfo, options: {} },
|
||||||
|
auth: undefined,
|
||||||
|
}),
|
||||||
|
).toEqual({ type: "unsupported", reason: "OpenAI API key is not configured" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("prefers console provider api key over stored opencode auth", () => {
|
||||||
|
expect(
|
||||||
|
LLMNativeRuntime.status({
|
||||||
|
model: { ...baseModel, providerID: ProviderID.make("opencode") },
|
||||||
|
provider: {
|
||||||
|
...providerInfo,
|
||||||
|
id: ProviderID.make("opencode"),
|
||||||
|
options: { apiKey: "console-token" },
|
||||||
|
key: "zen-token",
|
||||||
|
},
|
||||||
|
auth: { type: "api", key: "zen-token" },
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
type: "supported",
|
||||||
|
apiKey: "console-token",
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
LLMNativeRuntime.status({
|
||||||
|
model: baseModel,
|
||||||
|
provider: { ...providerInfo, options: {}, key: "provider-key" },
|
||||||
|
auth: undefined,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
type: "supported",
|
||||||
|
apiKey: "provider-key",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("native tool wrapper converts thrown errors into typed ToolFailure", async () => {
|
||||||
|
const wrapped = LLMNativeRuntime.nativeTools(
|
||||||
|
{
|
||||||
|
explode: {
|
||||||
|
description: "always throws",
|
||||||
|
inputSchema: jsonSchema({ type: "object" }),
|
||||||
|
execute: async () => {
|
||||||
|
throw new Error("boom")
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
{ messages: [] as ModelMessage[], abort: new AbortController().signal },
|
||||||
|
)
|
||||||
|
|
||||||
|
const failure = await Effect.runPromise(
|
||||||
|
Effect.flip(wrapped.explode!.execute!({}, { id: "call-1", name: "explode" })),
|
||||||
|
)
|
||||||
|
expect(failure).toBeInstanceOf(ToolFailure)
|
||||||
|
expect((failure as ToolFailure).message).toBe("boom")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("native tool wrapper raises ToolFailure when the source tool has no execute handler", async () => {
|
||||||
|
// The AI SDK Tool shape allows execute to be omitted (e.g., client-side / MCP tools).
|
||||||
|
// The native runtime owns execution, so encountering such a tool here means upstream
|
||||||
|
// wiring is wrong; we want a typed failure, not a silent skip or unhandled exception.
|
||||||
|
const wrapped = LLMNativeRuntime.nativeTools(
|
||||||
|
{ incomplete: { description: "no execute", inputSchema: jsonSchema({ type: "object" }) } as any },
|
||||||
|
{ messages: [] as ModelMessage[], abort: new AbortController().signal },
|
||||||
|
)
|
||||||
|
|
||||||
|
const failure = await Effect.runPromise(
|
||||||
|
Effect.flip(wrapped.incomplete!.execute!({}, { id: "call-1", name: "incomplete" })),
|
||||||
|
)
|
||||||
|
expect(failure).toBeInstanceOf(ToolFailure)
|
||||||
|
expect((failure as ToolFailure).message).toContain("incomplete")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("compiles through the native OpenAI Responses route", async () => {
|
||||||
|
const prepared = await Effect.runPromise(
|
||||||
|
LLMClient.prepare(
|
||||||
|
LLMNative.request({
|
||||||
|
model: baseModel,
|
||||||
|
messages: [{ role: "user", content: "hello" }],
|
||||||
|
providerOptions: { openai: { store: false } },
|
||||||
|
maxOutputTokens: 512,
|
||||||
|
headers: { "x-request": "request-header" },
|
||||||
|
}),
|
||||||
|
).pipe(Effect.provide(LLMClient.layer), Effect.provide(RequestExecutor.defaultLayer)),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared).toMatchObject({
|
||||||
|
route: "openai-responses",
|
||||||
|
protocol: "openai-responses",
|
||||||
|
body: {
|
||||||
|
model: "gpt-5-mini",
|
||||||
|
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
|
||||||
|
max_output_tokens: 512,
|
||||||
|
store: false,
|
||||||
|
stream: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
|
import { afterAll, beforeAll, beforeEach, describe, expect, 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, Effect, Exit, Stream } from "effect"
|
import { Cause, Effect, Exit, Layer, Stream } from "effect"
|
||||||
|
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { makeRuntime } from "../../src/effect/run-service"
|
import { makeRuntime } from "../../src/effect/run-service"
|
||||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||||
import { LLM } from "../../src/session/llm"
|
import { LLM } from "../../src/session/llm"
|
||||||
import type { InstanceContext } from "../../src/project/instance-context"
|
import type { InstanceContext } from "../../src/project/instance-context"
|
||||||
|
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
||||||
|
import { Auth } from "@/auth"
|
||||||
|
import { Config } from "@/config/config"
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import { ProviderTransform } from "@/provider/transform"
|
import { ProviderTransform } from "@/provider/transform"
|
||||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||||
|
import { Plugin } from "@/plugin"
|
||||||
import { ProviderID, ModelID } from "../../src/provider/schema"
|
import { ProviderID, ModelID } from "../../src/provider/schema"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { tmpdir, withTestInstance } from "../fixture/fixture"
|
import { tmpdir, withTestInstance } from "../fixture/fixture"
|
||||||
@@ -17,6 +22,33 @@ import type { Agent } from "../../src/agent/agent"
|
|||||||
import { MessageV2 } from "../../src/session/message-v2"
|
import { MessageV2 } from "../../src/session/message-v2"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||||
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
|
import { Permission } from "@/permission"
|
||||||
|
import { LLMAISDK } from "@/session/llm/ai-sdk"
|
||||||
|
import { Session as SessionNs } from "@/session/session"
|
||||||
|
|
||||||
|
const openAIConfig = (model: ModelsDev.Provider["models"][string], baseURL: string): Partial<Config.Info> => {
|
||||||
|
const { experimental: _experimental, ...configModel } = model
|
||||||
|
type ConfigModel = NonNullable<NonNullable<Config.Info["provider"]>[string]["models"]>[string]
|
||||||
|
return {
|
||||||
|
enabled_providers: ["openai"],
|
||||||
|
provider: {
|
||||||
|
openai: {
|
||||||
|
name: "OpenAI",
|
||||||
|
env: ["OPENAI_API_KEY"],
|
||||||
|
npm: "@ai-sdk/openai",
|
||||||
|
api: "https://api.openai.com/v1",
|
||||||
|
models: {
|
||||||
|
[model.id]: JSON.parse(JSON.stringify(configModel)) as ConfigModel,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
apiKey: "test-openai-key",
|
||||||
|
baseURL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getModel(providerID: ProviderID, modelID: ModelID, ctx: InstanceContext) {
|
async function getModel(providerID: ProviderID, modelID: ModelID, ctx: InstanceContext) {
|
||||||
const effect = Effect.gen(function* () {
|
const effect = Effect.gen(function* () {
|
||||||
@@ -35,6 +67,26 @@ async function drain(input: LLM.StreamInput, ctx: InstanceContext) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function drainWith(layer: Layer.Layer<LLM.Service>, input: LLM.StreamInput, ctx: InstanceContext) {
|
||||||
|
return Effect.runPromise(
|
||||||
|
LLM.Service.use((svc) => svc.stream(input).pipe(Stream.runDrain)).pipe(
|
||||||
|
Effect.provide(layer),
|
||||||
|
Effect.provideService(InstanceRef, ctx),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function llmLayerWithExecutor(executor: Layer.Layer<RequestExecutor.Service>, flags: Partial<RuntimeFlags.Info> = {}) {
|
||||||
|
return LLM.layer.pipe(
|
||||||
|
Layer.provide(Auth.defaultLayer),
|
||||||
|
Layer.provide(Config.defaultLayer),
|
||||||
|
Layer.provide(Provider.defaultLayer),
|
||||||
|
Layer.provide(Plugin.defaultLayer),
|
||||||
|
Layer.provide(LLMClient.layer.pipe(Layer.provide(executor))),
|
||||||
|
Layer.provide(RuntimeFlags.layer(flags)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
describe("session.llm.hasToolCalls", () => {
|
describe("session.llm.hasToolCalls", () => {
|
||||||
test("returns false for empty messages array", () => {
|
test("returns false for empty messages array", () => {
|
||||||
expect(LLM.hasToolCalls([])).toBe(false)
|
expect(LLM.hasToolCalls([])).toBe(false)
|
||||||
@@ -122,6 +174,338 @@ describe("session.llm.hasToolCalls", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("session.llm.ai-sdk adapter", () => {
|
||||||
|
type AISDKAdapterEvent = Parameters<typeof LLMAISDK.toLLMEvents>[1]
|
||||||
|
|
||||||
|
const adapt = (events: ReadonlyArray<AISDKAdapterEvent>) => {
|
||||||
|
const state = LLMAISDK.adapterState()
|
||||||
|
return Effect.runPromise(
|
||||||
|
Effect.forEach(events, (event) => LLMAISDK.toLLMEvents(state, event)).pipe(Effect.map((items) => items.flat())),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- tests defensive adapter branches outside AI SDK's current typed surface
|
||||||
|
const uncheckedAdapterEvent = (input: unknown) => input as AISDKAdapterEvent
|
||||||
|
|
||||||
|
test("maps AI SDK stream chunks without losing session-visible fields", async () => {
|
||||||
|
const metadata = { openai: { itemID: "item-1" } }
|
||||||
|
const events = await adapt([
|
||||||
|
{ type: "start" },
|
||||||
|
{ type: "start-step", request: {}, warnings: [] },
|
||||||
|
{ type: "text-start", id: "text-1", providerMetadata: metadata },
|
||||||
|
{ type: "text-delta", id: "text-1", text: "Hel", providerMetadata: { openai: { delta: 1 } } },
|
||||||
|
{ type: "text-delta", id: "text-1", text: "lo", providerMetadata: { openai: { delta: 2 } } },
|
||||||
|
{ type: "text-end", id: "text-1", providerMetadata: { openai: { done: true } } },
|
||||||
|
{ type: "reasoning-start", id: "reasoning-1", providerMetadata: metadata },
|
||||||
|
{ type: "reasoning-delta", id: "reasoning-1", text: "Think", providerMetadata: { openai: { delta: 3 } } },
|
||||||
|
{ type: "reasoning-end", id: "reasoning-1", providerMetadata: { openai: { done: true } } },
|
||||||
|
{ type: "tool-input-start", id: "call-1", toolName: "lookup", providerMetadata: metadata },
|
||||||
|
{ type: "tool-input-delta", id: "call-1", delta: '{"query":' },
|
||||||
|
{ type: "tool-input-delta", id: "call-1", delta: '"weather"}' },
|
||||||
|
{ type: "tool-input-end", id: "call-1", providerMetadata: { openai: { inputDone: true } } },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId: "call-1",
|
||||||
|
toolName: "lookup",
|
||||||
|
input: { query: "weather" },
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata: { openai: { called: true } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool-result",
|
||||||
|
toolCallId: "call-1",
|
||||||
|
toolName: "lookup",
|
||||||
|
input: { query: "weather" },
|
||||||
|
output: { title: "Lookup", output: "sunny", metadata: { ok: true } },
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata: { openai: { result: true } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "finish-step",
|
||||||
|
response: { id: "response-1", timestamp: new Date(0), modelId: "gpt-test" },
|
||||||
|
finishReason: "other",
|
||||||
|
rawFinishReason: "other",
|
||||||
|
usage: {
|
||||||
|
inputTokens: 10,
|
||||||
|
outputTokens: 5,
|
||||||
|
totalTokens: 15,
|
||||||
|
inputTokenDetails: { noCacheTokens: 5, cacheReadTokens: 3, cacheWriteTokens: 2 },
|
||||||
|
outputTokenDetails: { textTokens: 4, reasoningTokens: 1 },
|
||||||
|
},
|
||||||
|
providerMetadata: { openai: { step: true } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "finish",
|
||||||
|
finishReason: "other",
|
||||||
|
rawFinishReason: "other",
|
||||||
|
totalUsage: {
|
||||||
|
inputTokens: 11,
|
||||||
|
outputTokens: 6,
|
||||||
|
totalTokens: 17,
|
||||||
|
cachedInputTokens: 4,
|
||||||
|
reasoningTokens: 2,
|
||||||
|
inputTokenDetails: { noCacheTokens: 7, cacheReadTokens: 4, cacheWriteTokens: undefined },
|
||||||
|
outputTokenDetails: { textTokens: 4, reasoningTokens: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(events).toMatchObject([
|
||||||
|
{ type: "step-start", index: 0 },
|
||||||
|
{ type: "text-start", id: "text-1", providerMetadata: metadata },
|
||||||
|
{ type: "text-delta", id: "text-1", text: "Hel", providerMetadata: { openai: { delta: 1 } } },
|
||||||
|
{ type: "text-delta", id: "text-1", text: "lo", providerMetadata: { openai: { delta: 2 } } },
|
||||||
|
{ type: "text-end", id: "text-1", providerMetadata: { openai: { done: true } } },
|
||||||
|
{ type: "reasoning-start", id: "reasoning-1", providerMetadata: metadata },
|
||||||
|
{ type: "reasoning-delta", id: "reasoning-1", text: "Think", providerMetadata: { openai: { delta: 3 } } },
|
||||||
|
{ type: "reasoning-end", id: "reasoning-1", providerMetadata: { openai: { done: true } } },
|
||||||
|
{ type: "tool-input-start", id: "call-1", name: "lookup", providerMetadata: metadata },
|
||||||
|
{ type: "tool-input-delta", id: "call-1", name: "lookup", text: '{"query":' },
|
||||||
|
{ type: "tool-input-delta", id: "call-1", name: "lookup", text: '"weather"}' },
|
||||||
|
{ type: "tool-input-end", id: "call-1", name: "lookup", providerMetadata: { openai: { inputDone: true } } },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
id: "call-1",
|
||||||
|
name: "lookup",
|
||||||
|
input: { query: "weather" },
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata: { openai: { called: true } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool-result",
|
||||||
|
id: "call-1",
|
||||||
|
name: "lookup",
|
||||||
|
result: { type: "json", value: { title: "Lookup", output: "sunny", metadata: { ok: true } } },
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata: { openai: { result: true } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "step-finish",
|
||||||
|
index: 0,
|
||||||
|
reason: "unknown",
|
||||||
|
usage: {
|
||||||
|
inputTokens: 10,
|
||||||
|
outputTokens: 5,
|
||||||
|
totalTokens: 15,
|
||||||
|
reasoningTokens: 1,
|
||||||
|
cacheReadInputTokens: 3,
|
||||||
|
cacheWriteInputTokens: 2,
|
||||||
|
},
|
||||||
|
providerMetadata: { openai: { step: true } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "finish",
|
||||||
|
reason: "unknown",
|
||||||
|
usage: {
|
||||||
|
inputTokens: 11,
|
||||||
|
outputTokens: 6,
|
||||||
|
totalTokens: 17,
|
||||||
|
reasoningTokens: 2,
|
||||||
|
cacheReadInputTokens: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("creates stable block ids when AI SDK omits them", async () => {
|
||||||
|
const events = await adapt([
|
||||||
|
uncheckedAdapterEvent({ type: "text-delta", text: "implicit text" }),
|
||||||
|
uncheckedAdapterEvent({ type: "text-end" }),
|
||||||
|
uncheckedAdapterEvent({ type: "reasoning-delta", text: "implicit reasoning" }),
|
||||||
|
uncheckedAdapterEvent({ type: "reasoning-end" }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(events).toMatchObject([
|
||||||
|
{ type: "text-delta", id: "text-0", text: "implicit text" },
|
||||||
|
{ type: "text-end", id: "text-0" },
|
||||||
|
{ type: "reasoning-delta", id: "reasoning-0", text: "implicit reasoning" },
|
||||||
|
{ type: "reasoning-end", id: "reasoning-0" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("explicitly ignores non-session-visible AI SDK chunks", async () => {
|
||||||
|
expect(
|
||||||
|
await adapt([
|
||||||
|
uncheckedAdapterEvent({ type: "abort" }),
|
||||||
|
uncheckedAdapterEvent({ type: "source" }),
|
||||||
|
uncheckedAdapterEvent({ type: "file" }),
|
||||||
|
uncheckedAdapterEvent({ type: "raw" }),
|
||||||
|
uncheckedAdapterEvent({ type: "tool-output-denied" }),
|
||||||
|
uncheckedAdapterEvent({ type: "tool-approval-request" }),
|
||||||
|
]),
|
||||||
|
).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves tool-error cause", async () => {
|
||||||
|
const error = new Permission.RejectedError()
|
||||||
|
const events = await Effect.runPromise(
|
||||||
|
LLMAISDK.toLLMEvents(LLMAISDK.adapterState(), {
|
||||||
|
type: "tool-error",
|
||||||
|
toolCallId: "call_123",
|
||||||
|
toolName: "bash",
|
||||||
|
input: {},
|
||||||
|
error,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1)
|
||||||
|
expect(events[0]).toMatchObject({
|
||||||
|
type: "tool-error",
|
||||||
|
id: "call_123",
|
||||||
|
name: "bash",
|
||||||
|
message: error.message,
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("emits undefined usage when every AI SDK usage field is missing", async () => {
|
||||||
|
// If every numeric field is undefined the translator should signal "no usage info"
|
||||||
|
// by emitting undefined, not by polluting the event with usage: {}. Downstream cost
|
||||||
|
// telemetry distinguishes "missing" from "zero," so emitting an empty object causes
|
||||||
|
// false positives ("usage was tracked, just empty") instead of correct nulls.
|
||||||
|
const events = await adapt([
|
||||||
|
{
|
||||||
|
type: "finish-step",
|
||||||
|
response: { id: "response-1", timestamp: new Date(0), modelId: "gpt-test" },
|
||||||
|
finishReason: "stop",
|
||||||
|
rawFinishReason: "stop",
|
||||||
|
providerMetadata: undefined,
|
||||||
|
usage: {
|
||||||
|
inputTokens: undefined,
|
||||||
|
outputTokens: undefined,
|
||||||
|
totalTokens: undefined,
|
||||||
|
reasoningTokens: undefined,
|
||||||
|
cachedInputTokens: undefined,
|
||||||
|
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
|
||||||
|
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1)
|
||||||
|
const stepFinish = events[0]
|
||||||
|
if (stepFinish.type !== "step-finish") throw new Error("expected step-finish")
|
||||||
|
expect(stepFinish.usage).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reuses adapter state cleanly across streams once finish has fired", async () => {
|
||||||
|
// adapterState() is meant to be per-stream, but the only thing finish currently clears
|
||||||
|
// is toolNames — step, text counters, and the current text/reasoning IDs all leak
|
||||||
|
// forward. A caller that reuses a state across two streams sees text-1/reasoning-1/
|
||||||
|
// step index 1 on the second stream's first events. The test pins the intended
|
||||||
|
// contract: after finish, the same state can be reused and starts fresh.
|
||||||
|
const state = LLMAISDK.adapterState()
|
||||||
|
const run = (events: ReadonlyArray<AISDKAdapterEvent>) =>
|
||||||
|
Effect.runPromise(
|
||||||
|
Effect.forEach(events, (event) => LLMAISDK.toLLMEvents(state, event)).pipe(Effect.map((items) => items.flat())),
|
||||||
|
)
|
||||||
|
|
||||||
|
await run([
|
||||||
|
{ type: "start-step", request: {}, warnings: [] },
|
||||||
|
uncheckedAdapterEvent({ type: "text-delta", text: "first" }),
|
||||||
|
uncheckedAdapterEvent({ type: "text-end" }),
|
||||||
|
uncheckedAdapterEvent({ type: "reasoning-delta", text: "first reasoning" }),
|
||||||
|
uncheckedAdapterEvent({ type: "reasoning-end" }),
|
||||||
|
{
|
||||||
|
type: "finish-step",
|
||||||
|
response: { id: "r1", timestamp: new Date(0), modelId: "gpt-test" },
|
||||||
|
finishReason: "stop",
|
||||||
|
rawFinishReason: "stop",
|
||||||
|
providerMetadata: undefined,
|
||||||
|
usage: {
|
||||||
|
inputTokens: 1,
|
||||||
|
outputTokens: 1,
|
||||||
|
totalTokens: 2,
|
||||||
|
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
|
||||||
|
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "finish",
|
||||||
|
finishReason: "stop",
|
||||||
|
rawFinishReason: "stop",
|
||||||
|
totalUsage: {
|
||||||
|
inputTokens: 1,
|
||||||
|
outputTokens: 1,
|
||||||
|
totalTokens: 2,
|
||||||
|
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
|
||||||
|
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const secondStream = await run([
|
||||||
|
{ type: "start-step", request: {}, warnings: [] },
|
||||||
|
uncheckedAdapterEvent({ type: "text-delta", text: "second" }),
|
||||||
|
uncheckedAdapterEvent({ type: "text-end" }),
|
||||||
|
uncheckedAdapterEvent({ type: "reasoning-delta", text: "second reasoning" }),
|
||||||
|
uncheckedAdapterEvent({ type: "reasoning-end" }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(secondStream).toMatchObject([
|
||||||
|
{ type: "step-start", index: 0 },
|
||||||
|
{ type: "text-delta", id: "text-0", text: "second" },
|
||||||
|
{ type: "text-end", id: "text-0" },
|
||||||
|
{ type: "reasoning-delta", id: "reasoning-0", text: "second reasoning" },
|
||||||
|
{ type: "reasoning-end", id: "reasoning-0" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Anthropic emits cache write counts in providerMetadata.anthropic.cacheCreationInputTokens
|
||||||
|
// rather than usage.inputTokenDetails.cacheWriteTokens. Session.getUsage falls back to the
|
||||||
|
// metadata path — but only if the adapter preserves providerMetadata on step-finish.
|
||||||
|
test("preserves providerMetadata on step-finish so Anthropic cache writes survive getUsage", async () => {
|
||||||
|
const events = await adapt([
|
||||||
|
{
|
||||||
|
type: "finish-step",
|
||||||
|
response: { id: "msg_test", timestamp: new Date(0), modelId: "claude-3-5-sonnet" },
|
||||||
|
finishReason: "stop",
|
||||||
|
rawFinishReason: "stop",
|
||||||
|
// Anthropic's AI SDK shape: cacheWriteTokens is NOT in usage, it arrives via providerMetadata.
|
||||||
|
usage: {
|
||||||
|
inputTokens: 1000,
|
||||||
|
outputTokens: 500,
|
||||||
|
totalTokens: 1500,
|
||||||
|
inputTokenDetails: { noCacheTokens: 800, cacheReadTokens: 200, cacheWriteTokens: undefined },
|
||||||
|
outputTokenDetails: { textTokens: 500, reasoningTokens: undefined },
|
||||||
|
},
|
||||||
|
providerMetadata: { anthropic: { cacheCreationInputTokens: 300 } },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1)
|
||||||
|
const stepFinish = events[0]
|
||||||
|
if (stepFinish.type !== "step-finish") throw new Error("expected step-finish")
|
||||||
|
expect(stepFinish.providerMetadata).toEqual({ anthropic: { cacheCreationInputTokens: 300 } })
|
||||||
|
expect(stepFinish.usage?.cacheWriteInputTokens).toBeUndefined()
|
||||||
|
expect(stepFinish.usage?.cacheReadInputTokens).toBe(200)
|
||||||
|
|
||||||
|
// End-to-end: with the metadata preserved, getUsage extracts cache.write from the fallback path.
|
||||||
|
const result = SessionNs.getUsage({
|
||||||
|
model: {
|
||||||
|
id: "claude-3-5-sonnet",
|
||||||
|
providerID: "anthropic",
|
||||||
|
name: "Claude",
|
||||||
|
limit: { context: 200_000, output: 8_000 },
|
||||||
|
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 never,
|
||||||
|
usage: stepFinish.usage!,
|
||||||
|
metadata: stepFinish.providerMetadata,
|
||||||
|
})
|
||||||
|
expect(result.tokens.cache.write).toBe(300)
|
||||||
|
expect(result.tokens.cache.read).toBe(200)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
type Capture = {
|
type Capture = {
|
||||||
url: URL
|
url: URL
|
||||||
headers: Headers
|
headers: Headers
|
||||||
@@ -608,6 +992,18 @@ describe("session.llm.stream", () => {
|
|||||||
service_tier: null,
|
service_tier: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
output_index: 0,
|
||||||
|
item: { type: "message", id: "item-1", status: "in_progress", role: "assistant", content: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.content_part.added",
|
||||||
|
item_id: "item-1",
|
||||||
|
output_index: 0,
|
||||||
|
content_index: 0,
|
||||||
|
part: { type: "output_text", text: "", annotations: [] },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "response.output_text.delta",
|
type: "response.output_text.delta",
|
||||||
item_id: "item-1",
|
item_id: "item-1",
|
||||||
@@ -630,32 +1026,7 @@ describe("session.llm.stream", () => {
|
|||||||
]
|
]
|
||||||
const request = waitRequest("/responses", createEventResponse(responseChunks, true))
|
const request = waitRequest("/responses", createEventResponse(responseChunks, true))
|
||||||
|
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({ config: openAIConfig(model, `${server.url.origin}/v1`) })
|
||||||
init: async (dir) => {
|
|
||||||
await Bun.write(
|
|
||||||
path.join(dir, "opencode.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
enabled_providers: ["openai"],
|
|
||||||
provider: {
|
|
||||||
openai: {
|
|
||||||
name: "OpenAI",
|
|
||||||
env: ["OPENAI_API_KEY"],
|
|
||||||
npm: "@ai-sdk/openai",
|
|
||||||
api: "https://api.openai.com/v1",
|
|
||||||
models: {
|
|
||||||
[model.id]: configModel(model),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
apiKey: "test-openai-key",
|
|
||||||
baseURL: `${server.url.origin}/v1`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
@@ -706,6 +1077,438 @@ describe("session.llm.stream", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keeps supported OpenAI models on AI SDK path when native flag is off", async () => {
|
||||||
|
const server = state.server
|
||||||
|
if (!server) {
|
||||||
|
throw new Error("Server not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = await loadFixture("openai", "gpt-5.2")
|
||||||
|
const model = source.model
|
||||||
|
const request = waitRequest(
|
||||||
|
"/responses",
|
||||||
|
createEventResponse(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "response.created",
|
||||||
|
response: {
|
||||||
|
id: "resp-flag-off",
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
model: model.id,
|
||||||
|
service_tier: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
output_index: 0,
|
||||||
|
item: { type: "message", id: "item-flag-off", status: "in_progress", role: "assistant", content: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.content_part.added",
|
||||||
|
item_id: "item-flag-off",
|
||||||
|
output_index: 0,
|
||||||
|
content_index: 0,
|
||||||
|
part: { type: "output_text", text: "", annotations: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_text.delta",
|
||||||
|
item_id: "item-flag-off",
|
||||||
|
delta: "Flag off",
|
||||||
|
logprobs: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.completed",
|
||||||
|
response: {
|
||||||
|
incomplete_details: null,
|
||||||
|
usage: {
|
||||||
|
input_tokens: 1,
|
||||||
|
input_tokens_details: null,
|
||||||
|
output_tokens: 1,
|
||||||
|
output_tokens_details: null,
|
||||||
|
},
|
||||||
|
service_tier: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const failingNativeClient = Layer.succeed(
|
||||||
|
LLMClient.Service,
|
||||||
|
LLMClient.Service.of({
|
||||||
|
prepare: () => Effect.die(new Error("native LLM client should not be used when the flag is off")),
|
||||||
|
stream: () => Stream.die(new Error("native LLM client should not be used when the flag is off")),
|
||||||
|
generate: () => Effect.die(new Error("native LLM client should not be used when the flag is off")),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await using tmp = await tmpdir({ config: openAIConfig(model, `${server.url.origin}/v1`) })
|
||||||
|
|
||||||
|
await withTestInstance({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async (ctx) => {
|
||||||
|
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx)
|
||||||
|
const sessionID = SessionID.make("session-test-native-flag-off")
|
||||||
|
const agent = {
|
||||||
|
name: "test",
|
||||||
|
mode: "primary",
|
||||||
|
options: {},
|
||||||
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||||
|
} satisfies Agent.Info
|
||||||
|
|
||||||
|
await drainWith(
|
||||||
|
LLM.layer.pipe(
|
||||||
|
Layer.provide(Auth.defaultLayer),
|
||||||
|
Layer.provide(Config.defaultLayer),
|
||||||
|
Layer.provide(Provider.defaultLayer),
|
||||||
|
Layer.provide(Plugin.defaultLayer),
|
||||||
|
Layer.provide(failingNativeClient),
|
||||||
|
Layer.provide(RuntimeFlags.layer({ experimentalNativeLlm: false })),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
user: {
|
||||||
|
id: MessageID.make("msg_user-native-flag-off"),
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: Date.now() },
|
||||||
|
agent: agent.name,
|
||||||
|
model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" },
|
||||||
|
} satisfies MessageV2.User,
|
||||||
|
sessionID,
|
||||||
|
model: resolved,
|
||||||
|
agent,
|
||||||
|
system: ["You are a helpful assistant."],
|
||||||
|
messages: [{ role: "user", content: "Hello" }],
|
||||||
|
tools: {},
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
const capture = await request
|
||||||
|
expect(capture.url.pathname.endsWith("/responses")).toBe(true)
|
||||||
|
expect(capture.body.model).toBe(resolved.api.id)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("streams OpenAI through native runtime when opted in", async () => {
|
||||||
|
const server = state.server
|
||||||
|
if (!server) {
|
||||||
|
throw new Error("Server not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = await loadFixture("openai", "gpt-5.2")
|
||||||
|
const model = source.model
|
||||||
|
const chunks = [
|
||||||
|
{
|
||||||
|
type: "response.created",
|
||||||
|
response: {
|
||||||
|
id: "resp-native",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "message", id: "item-native", status: "in_progress" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_text.delta",
|
||||||
|
item_id: "item-native",
|
||||||
|
delta: "Hello native",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.completed",
|
||||||
|
response: {
|
||||||
|
incomplete_details: null,
|
||||||
|
usage: {
|
||||||
|
input_tokens: 1,
|
||||||
|
input_tokens_details: null,
|
||||||
|
output_tokens: 1,
|
||||||
|
output_tokens_details: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const request = waitRequest("/responses", createEventResponse(chunks, true))
|
||||||
|
|
||||||
|
await using tmp = await tmpdir({ config: openAIConfig(model, `${server.url.origin}/v1`) })
|
||||||
|
|
||||||
|
await withTestInstance({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async (ctx) => {
|
||||||
|
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx)
|
||||||
|
const sessionID = SessionID.make("session-test-native")
|
||||||
|
const agent = {
|
||||||
|
name: "test",
|
||||||
|
mode: "primary",
|
||||||
|
options: {},
|
||||||
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||||
|
temperature: 0.2,
|
||||||
|
} satisfies Agent.Info
|
||||||
|
|
||||||
|
await drainWith(
|
||||||
|
llmLayerWithExecutor(RequestExecutor.defaultLayer, { experimentalNativeLlm: true }),
|
||||||
|
{
|
||||||
|
user: {
|
||||||
|
id: MessageID.make("msg_user-native"),
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: Date.now() },
|
||||||
|
agent: agent.name,
|
||||||
|
model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" },
|
||||||
|
} satisfies MessageV2.User,
|
||||||
|
sessionID,
|
||||||
|
model: resolved,
|
||||||
|
agent,
|
||||||
|
system: ["You are a helpful assistant."],
|
||||||
|
messages: [{ role: "user", content: "Hello" }],
|
||||||
|
tools: {},
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
const capture = await request
|
||||||
|
expect(capture.url.pathname.endsWith("/responses")).toBe(true)
|
||||||
|
expect(capture.headers.get("Authorization")).toBe("Bearer test-openai-key")
|
||||||
|
expect(capture.body.model).toBe(model.id)
|
||||||
|
expect(capture.body.stream).toBe(true)
|
||||||
|
expect((capture.body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
|
||||||
|
expect(JSON.stringify(capture.body.input)).toContain("You are a helpful assistant.")
|
||||||
|
expect(capture.body.input).toContainEqual({ role: "user", content: [{ type: "input_text", text: "Hello" }] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses injected native request executor for tool calls", async () => {
|
||||||
|
const source = await loadFixture("openai", "gpt-5.2")
|
||||||
|
const model = source.model
|
||||||
|
const chunks = [
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "function_call", id: "item-injected-tool", call_id: "call-injected-tool", name: "lookup" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.function_call_arguments.delta",
|
||||||
|
item_id: "item-injected-tool",
|
||||||
|
delta: '{"query":"weather"}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: {
|
||||||
|
type: "function_call",
|
||||||
|
id: "item-injected-tool",
|
||||||
|
call_id: "call-injected-tool",
|
||||||
|
name: "lookup",
|
||||||
|
arguments: '{"query":"weather"}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.completed",
|
||||||
|
response: { incomplete_details: null, usage: { input_tokens: 1, output_tokens: 1 } },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
let captured: Record<string, unknown> | undefined
|
||||||
|
let executed: unknown
|
||||||
|
const executor = Layer.succeed(
|
||||||
|
RequestExecutor.Service,
|
||||||
|
RequestExecutor.Service.of({
|
||||||
|
execute: (request) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
|
||||||
|
captured = (yield* Effect.promise(() => web.json())) as Record<string, unknown>
|
||||||
|
return HttpClientResponse.fromWeb(request, createEventResponse(chunks, true))
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await using tmp = await tmpdir({ config: openAIConfig(model, "https://injected-openai.test/v1") })
|
||||||
|
|
||||||
|
await withTestInstance({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async (ctx) => {
|
||||||
|
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx)
|
||||||
|
const sessionID = SessionID.make("session-test-native-injected-tool")
|
||||||
|
const agent = {
|
||||||
|
name: "test",
|
||||||
|
mode: "primary",
|
||||||
|
options: {},
|
||||||
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||||
|
} satisfies Agent.Info
|
||||||
|
|
||||||
|
await drainWith(
|
||||||
|
llmLayerWithExecutor(executor, { experimentalNativeLlm: true }),
|
||||||
|
{
|
||||||
|
user: {
|
||||||
|
id: MessageID.make("msg_user-native-injected-tool"),
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: Date.now() },
|
||||||
|
agent: agent.name,
|
||||||
|
model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
|
||||||
|
} satisfies MessageV2.User,
|
||||||
|
sessionID,
|
||||||
|
model: resolved,
|
||||||
|
agent,
|
||||||
|
system: [],
|
||||||
|
messages: [{ role: "user", content: "Use lookup" }],
|
||||||
|
tools: {
|
||||||
|
lookup: tool({
|
||||||
|
description: "Lookup data",
|
||||||
|
inputSchema: z.object({ query: z.string() }),
|
||||||
|
execute: async (args, options) => {
|
||||||
|
executed = { args, toolCallId: options.toolCallId }
|
||||||
|
return { output: "looked up" }
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(captured?.model).toBe(model.id)
|
||||||
|
expect(captured?.tools).toEqual([
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
name: "lookup",
|
||||||
|
description: "Lookup data",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: { query: { type: "string" } },
|
||||||
|
required: ["query"],
|
||||||
|
additionalProperties: false,
|
||||||
|
$schema: "http://json-schema.org/draft-07/schema#",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(executed).toEqual({ args: { query: "weather" }, toolCallId: "call-injected-tool" })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("executes OpenAI tool calls through native runtime", async () => {
|
||||||
|
const server = state.server
|
||||||
|
if (!server) {
|
||||||
|
throw new Error("Server not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = await loadFixture("openai", "gpt-5.2")
|
||||||
|
const model = source.model
|
||||||
|
const chunks = [
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "function_call", id: "item-native-tool", call_id: "call-native-tool", name: "lookup" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.function_call_arguments.delta",
|
||||||
|
item_id: "item-native-tool",
|
||||||
|
delta: '{"query":"weather"}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: {
|
||||||
|
type: "function_call",
|
||||||
|
id: "item-native-tool",
|
||||||
|
call_id: "call-native-tool",
|
||||||
|
name: "lookup",
|
||||||
|
arguments: '{"query":"weather"}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.completed",
|
||||||
|
response: { incomplete_details: null, usage: { input_tokens: 1, output_tokens: 1 } },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const request = waitRequest("/responses", createEventResponse(chunks, true))
|
||||||
|
let executed: unknown
|
||||||
|
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
enabled_providers: ["openai"],
|
||||||
|
provider: {
|
||||||
|
openai: {
|
||||||
|
name: "OpenAI",
|
||||||
|
env: ["OPENAI_API_KEY"],
|
||||||
|
npm: "@ai-sdk/openai",
|
||||||
|
api: "https://api.openai.com/v1",
|
||||||
|
models: {
|
||||||
|
[model.id]: model,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
apiKey: "test-openai-key",
|
||||||
|
baseURL: `${server.url.origin}/v1`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await withTestInstance({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async (ctx) => {
|
||||||
|
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx)
|
||||||
|
const sessionID = SessionID.make("session-test-native-tool")
|
||||||
|
const agent = {
|
||||||
|
name: "test",
|
||||||
|
mode: "primary",
|
||||||
|
options: {},
|
||||||
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||||
|
} satisfies Agent.Info
|
||||||
|
|
||||||
|
await drainWith(
|
||||||
|
llmLayerWithExecutor(RequestExecutor.defaultLayer, { experimentalNativeLlm: true }),
|
||||||
|
{
|
||||||
|
user: {
|
||||||
|
id: MessageID.make("msg_user-native-tool"),
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: Date.now() },
|
||||||
|
agent: agent.name,
|
||||||
|
model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
|
||||||
|
} satisfies MessageV2.User,
|
||||||
|
sessionID,
|
||||||
|
model: resolved,
|
||||||
|
agent,
|
||||||
|
system: [],
|
||||||
|
messages: [{ role: "user", content: "Use lookup" }],
|
||||||
|
tools: {
|
||||||
|
lookup: tool({
|
||||||
|
description: "Lookup data",
|
||||||
|
inputSchema: z.object({ query: z.string() }),
|
||||||
|
execute: async (args, options) => {
|
||||||
|
executed = { args, toolCallId: options.toolCallId }
|
||||||
|
return { output: "looked up" }
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
const capture = await request
|
||||||
|
expect(capture.body.tools).toEqual([
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
name: "lookup",
|
||||||
|
description: "Lookup data",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: { query: { type: "string" } },
|
||||||
|
required: ["query"],
|
||||||
|
additionalProperties: false,
|
||||||
|
$schema: "http://json-schema.org/draft-07/schema#",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(executed).toEqual({ args: { query: "weather" }, toolCallId: "call-native-tool" })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("accepts user image attachments as data URLs for OpenAI models", async () => {
|
test("accepts user image attachments as data URLs for OpenAI models", async () => {
|
||||||
const server = state.server
|
const server = state.server
|
||||||
if (!server) {
|
if (!server) {
|
||||||
@@ -724,6 +1527,18 @@ describe("session.llm.stream", () => {
|
|||||||
service_tier: null,
|
service_tier: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
output_index: 0,
|
||||||
|
item: { type: "message", id: "item-data-url", status: "in_progress", role: "assistant", content: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.content_part.added",
|
||||||
|
item_id: "item-data-url",
|
||||||
|
output_index: 0,
|
||||||
|
content_index: 0,
|
||||||
|
part: { type: "output_text", text: "", annotations: [] },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "response.output_text.delta",
|
type: "response.output_text.delta",
|
||||||
item_id: "item-data-url",
|
item_id: "item-data-url",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { expect } from "bun:test"
|
import { expect } from "bun:test"
|
||||||
|
import { tool } from "ai"
|
||||||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import z from "zod"
|
||||||
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"
|
||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
@@ -249,7 +251,7 @@ it.live("session.processor effect tests capture llm input cleanly", () =>
|
|||||||
expect(calls).toBe(1)
|
expect(calls).toBe(1)
|
||||||
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) },
|
{ config: (url) => providerCfg(url) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -331,7 +333,7 @@ it.live("session.processor effect tests preserve text start time", () =>
|
|||||||
if (!text?.time?.start || !text.time.end) return
|
if (!text?.time?.start || !text.time.end) return
|
||||||
expect(text.time.start).toBeLessThan(text.time.end)
|
expect(text.time.start).toBeLessThan(text.time.end)
|
||||||
}),
|
}),
|
||||||
{ git: true, config: (url) => providerCfg(url) },
|
{ config: (url) => providerCfg(url) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -377,7 +379,7 @@ it.live("session.processor effect tests stop after token overflow requests compa
|
|||||||
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(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) },
|
{ config: (url) => providerCfg(url) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -425,7 +427,7 @@ it.live("session.processor effect tests capture reasoning from http mock", () =>
|
|||||||
expect(reasoning?.text).toBe("think")
|
expect(reasoning?.text).toBe("think")
|
||||||
expect(text?.text).toBe("done")
|
expect(text?.text).toBe("done")
|
||||||
}),
|
}),
|
||||||
{ git: true, config: (url) => providerCfg(url) },
|
{ config: (url) => providerCfg(url) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -472,7 +474,7 @@ it.live("session.processor effect tests reset reasoning state across retries", (
|
|||||||
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) },
|
{ config: (url) => providerCfg(url) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -515,7 +517,7 @@ it.live("session.processor effect tests do not retry unknown json errors", () =>
|
|||||||
expect(yield* llm.calls).toBe(1)
|
expect(yield* llm.calls).toBe(1)
|
||||||
expect(handle.message.error?.name).toBe("APIError")
|
expect(handle.message.error?.name).toBe("APIError")
|
||||||
}),
|
}),
|
||||||
{ git: true, config: (url) => providerCfg(url) },
|
{ config: (url) => providerCfg(url) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -562,7 +564,7 @@ it.live("session.processor effect tests retry recognized structured json errors"
|
|||||||
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) },
|
{ config: (url) => providerCfg(url) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -614,7 +616,7 @@ it.live("session.processor effect tests publish retry status updates", () =>
|
|||||||
expect(yield* llm.calls).toBe(2)
|
expect(yield* llm.calls).toBe(2)
|
||||||
expect(states).toStrictEqual([1])
|
expect(states).toStrictEqual([1])
|
||||||
}),
|
}),
|
||||||
{ git: true, config: (url) => providerCfg(url) },
|
{ config: (url) => providerCfg(url) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -657,6 +659,71 @@ it.live("session.processor effect tests compact on structured context overflow",
|
|||||||
expect(yield* llm.calls).toBe(1)
|
expect(yield* llm.calls).toBe(1)
|
||||||
expect(handle.message.error).toBeUndefined()
|
expect(handle.message.error).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
|
{ config: (url) => providerCfg(url) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("session.processor effect tests complete AI SDK tool calls when native flag is off", () =>
|
||||||
|
provideTmpdirServer(
|
||||||
|
({ dir, llm }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { processors, session, provider } = yield* boot()
|
||||||
|
|
||||||
|
yield* llm.tool("lookup", { query: "weather" })
|
||||||
|
|
||||||
|
const chat = yield* session.create({})
|
||||||
|
const parent = yield* user(chat.id, "tool")
|
||||||
|
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({
|
||||||
|
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: "tool" }],
|
||||||
|
tools: {
|
||||||
|
lookup: tool({
|
||||||
|
description: "Look up information",
|
||||||
|
inputSchema: z.object({ query: z.string() }),
|
||||||
|
execute: async (input) => ({
|
||||||
|
title: "Weather lookup",
|
||||||
|
output: `result:${input.query}`,
|
||||||
|
metadata: { source: "test" },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const parts = MessageV2.parts(msg.id)
|
||||||
|
const call = parts.find((part): part is MessageV2.ToolPart => part.type === "tool")
|
||||||
|
|
||||||
|
expect(value).toBe("continue")
|
||||||
|
expect(yield* llm.calls).toBe(1)
|
||||||
|
expect(call?.callID).toBe("call_1")
|
||||||
|
expect(call?.tool).toBe("lookup")
|
||||||
|
expect(call?.state.status).toBe("completed")
|
||||||
|
if (call?.state.status !== "completed") return
|
||||||
|
expect(call.state.input).toEqual({ query: "weather" })
|
||||||
|
expect(call.state.output).toBe("result:weather")
|
||||||
|
expect(call.state.title).toBe("Weather lookup")
|
||||||
|
expect(call.state.metadata).toEqual({ source: "test" })
|
||||||
|
expect(call.state.time.start).toBeDefined()
|
||||||
|
expect(call.state.time.end).toBeDefined()
|
||||||
|
}),
|
||||||
{ git: true, config: (url) => providerCfg(url) },
|
{ git: true, config: (url) => providerCfg(url) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -721,7 +788,7 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup
|
|||||||
expect(call.state.time.end).toBeDefined()
|
expect(call.state.time.end).toBeDefined()
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
{ git: true, config: (url) => providerCfg(url) },
|
{ config: (url) => providerCfg(url) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -793,7 +860,7 @@ 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) },
|
{ config: (url) => providerCfg(url) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -850,6 +917,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) },
|
{ config: (url) => providerCfg(url) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import * as Database from "../../src/storage/db"
|
|||||||
import { Ripgrep } from "../../src/file/ripgrep"
|
import { Ripgrep } from "../../src/file/ripgrep"
|
||||||
import { Format } from "../../src/format"
|
import { Format } from "../../src/format"
|
||||||
import { Reference } from "../../src/reference/reference"
|
import { Reference } from "../../src/reference/reference"
|
||||||
|
import { RepositoryCache } from "../../src/reference/repository-cache"
|
||||||
import { TestInstance } from "../fixture/fixture"
|
import { TestInstance } from "../fixture/fixture"
|
||||||
import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
|
import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
|
||||||
import { reply, TestLLMServer } from "../lib/llm-server"
|
import { reply, TestLLMServer } from "../lib/llm-server"
|
||||||
@@ -189,6 +190,7 @@ function makeHttp(input?: { processor?: "blocking" }) {
|
|||||||
Layer.provide(Skill.defaultLayer),
|
Layer.provide(Skill.defaultLayer),
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
|
Layer.provide(RepositoryCache.defaultLayer),
|
||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Git.defaultLayer),
|
||||||
Layer.provide(Reference.defaultLayer),
|
Layer.provide(Reference.defaultLayer),
|
||||||
Layer.provide(Ripgrep.defaultLayer),
|
Layer.provide(Ripgrep.defaultLayer),
|
||||||
@@ -431,9 +433,7 @@ const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) {
|
|||||||
|
|
||||||
// Loop semantics
|
// Loop semantics
|
||||||
|
|
||||||
it.instance(
|
it.instance("loop exits immediately when last assistant has stop finish", () =>
|
||||||
"loop exits immediately when last assistant has stop finish",
|
|
||||||
() =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { llm } = yield* useServerConfig(providerCfg)
|
const { llm } = yield* useServerConfig(providerCfg)
|
||||||
const prompt = yield* SessionPrompt.Service
|
const prompt = yield* SessionPrompt.Service
|
||||||
@@ -446,12 +446,9 @@ it.instance(
|
|||||||
if (result.info.role === "assistant") expect(result.info.finish).toBe("stop")
|
if (result.info.role === "assistant") expect(result.info.finish).toBe("stop")
|
||||||
expect(yield* llm.calls).toBe(0)
|
expect(yield* llm.calls).toBe(0)
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
it.instance(
|
it.instance("loop calls LLM and returns assistant message", () =>
|
||||||
"loop calls LLM and returns assistant message",
|
|
||||||
() =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { llm } = yield* useServerConfig(providerCfg)
|
const { llm } = yield* useServerConfig(providerCfg)
|
||||||
const prompt = yield* SessionPrompt.Service
|
const prompt = yield* SessionPrompt.Service
|
||||||
@@ -474,12 +471,9 @@ it.instance(
|
|||||||
expect(parts.some((p) => p.type === "text" && p.text === "world")).toBe(true)
|
expect(parts.some((p) => p.type === "text" && p.text === "world")).toBe(true)
|
||||||
expect(yield* llm.hits).toHaveLength(1)
|
expect(yield* llm.hits).toHaveLength(1)
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
it.instance(
|
it.instance("prompt emits v2 prompted and synthetic events", () =>
|
||||||
"prompt emits v2 prompted and synthetic events",
|
|
||||||
() =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* useServerConfig(providerCfg)
|
yield* useServerConfig(providerCfg)
|
||||||
const prompt = yield* SessionPrompt.Service
|
const prompt = yield* SessionPrompt.Service
|
||||||
@@ -516,12 +510,9 @@ it.instance(
|
|||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
it.instance(
|
it.instance("static loop returns assistant text through local provider", () =>
|
||||||
"static loop returns assistant text through local provider",
|
|
||||||
() =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { llm } = yield* useServerConfig(providerCfg)
|
const { llm } = yield* useServerConfig(providerCfg)
|
||||||
const prompt = yield* SessionPrompt.Service
|
const prompt = yield* SessionPrompt.Service
|
||||||
@@ -546,12 +537,9 @@ it.instance(
|
|||||||
expect(yield* llm.hits).toHaveLength(1)
|
expect(yield* llm.hits).toHaveLength(1)
|
||||||
expect(yield* llm.pending).toBe(0)
|
expect(yield* llm.pending).toBe(0)
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
it.instance(
|
it.instance("static loop consumes queued replies across turns", () =>
|
||||||
"static loop consumes queued replies across turns",
|
|
||||||
() =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { llm } = yield* useServerConfig(providerCfg)
|
const { llm } = yield* useServerConfig(providerCfg)
|
||||||
const prompt = yield* SessionPrompt.Service
|
const prompt = yield* SessionPrompt.Service
|
||||||
@@ -590,12 +578,9 @@ it.instance(
|
|||||||
expect(yield* llm.hits).toHaveLength(2)
|
expect(yield* llm.hits).toHaveLength(2)
|
||||||
expect(yield* llm.pending).toBe(0)
|
expect(yield* llm.pending).toBe(0)
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
it.instance(
|
it.instance("loop continues when finish is tool-calls", () =>
|
||||||
"loop continues when finish is tool-calls",
|
|
||||||
() =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { llm } = yield* useServerConfig(providerCfg)
|
const { llm } = yield* useServerConfig(providerCfg)
|
||||||
const prompt = yield* SessionPrompt.Service
|
const prompt = yield* SessionPrompt.Service
|
||||||
@@ -621,12 +606,9 @@ it.instance(
|
|||||||
expect(result.info.finish).toBe("stop")
|
expect(result.info.finish).toBe("stop")
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
it.instance(
|
it.instance("glob tool keeps instance context during prompt runs", () =>
|
||||||
"glob tool keeps instance context during prompt runs",
|
|
||||||
() =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { dir, llm } = yield* useServerConfig(providerCfg)
|
const { dir, llm } = yield* useServerConfig(providerCfg)
|
||||||
const prompt = yield* SessionPrompt.Service
|
const prompt = yield* SessionPrompt.Service
|
||||||
@@ -663,12 +645,9 @@ it.instance(
|
|||||||
expect(tool.state.output).not.toContain("No context found for instance")
|
expect(tool.state.output).not.toContain("No context found for instance")
|
||||||
expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true)
|
expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true)
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
it.instance(
|
it.instance("loop continues when finish is stop but assistant has tool parts", () =>
|
||||||
"loop continues when finish is stop but assistant has tool parts",
|
|
||||||
() =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { llm } = yield* useServerConfig(providerCfg)
|
const { llm } = yield* useServerConfig(providerCfg)
|
||||||
const prompt = yield* SessionPrompt.Service
|
const prompt = yield* SessionPrompt.Service
|
||||||
@@ -694,12 +673,9 @@ it.instance(
|
|||||||
expect(result.info.finish).toBe("stop")
|
expect(result.info.finish).toBe("stop")
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
it.instance(
|
it.instance("failed subtask preserves metadata on error tool state", () =>
|
||||||
"failed subtask preserves metadata on error tool state",
|
|
||||||
() =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { llm } = yield* useServerConfig((url) => ({
|
const { llm } = yield* useServerConfig((url) => ({
|
||||||
...providerCfg(url),
|
...providerCfg(url),
|
||||||
@@ -741,7 +717,6 @@ it.instance(
|
|||||||
modelID: ModelID.make("missing-model"),
|
modelID: ModelID.make("missing-model"),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
it.instance(
|
it.instance(
|
||||||
@@ -776,7 +751,6 @@ it.instance(
|
|||||||
yield* prompt.cancel(chat.id)
|
yield* prompt.cancel(chat.id)
|
||||||
yield* Fiber.await(fiber)
|
yield* Fiber.await(fiber)
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
5_000,
|
5_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -821,7 +795,6 @@ it.instance(
|
|||||||
yield* prompt.cancel(chat.id)
|
yield* prompt.cancel(chat.id)
|
||||||
yield* Fiber.await(fiber)
|
yield* Fiber.await(fiber)
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
10_000,
|
10_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -846,7 +819,6 @@ it.instance(
|
|||||||
yield* Fiber.await(fiber)
|
yield* Fiber.await(fiber)
|
||||||
expect((yield* status.get(chat.id)).type).toBe("idle")
|
expect((yield* status.get(chat.id)).type).toBe("idle")
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
3_000,
|
3_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -875,7 +847,6 @@ it.instance(
|
|||||||
expect(exit.value.info.role).toBe("assistant")
|
expect(exit.value.info.role).toBe("assistant")
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
3_000,
|
3_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -902,7 +873,6 @@ it.instance(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
3_000,
|
3_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -992,7 +962,6 @@ race.instance(
|
|||||||
expect(lastAssistant.info.parentID).toBe(lastUser?.info.id)
|
expect(lastAssistant.info.parentID).toBe(lastUser?.info.id)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
3_000,
|
3_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1039,7 +1008,7 @@ it.instance(
|
|||||||
expect(taskMsg.info.time.completed).toBeDefined()
|
expect(taskMsg.info.time.completed).toBeDefined()
|
||||||
expect(taskMsg.info.finish).toBeDefined()
|
expect(taskMsg.info.finish).toBeDefined()
|
||||||
}),
|
}),
|
||||||
{ git: true, config: cfg },
|
{ config: cfg },
|
||||||
30_000,
|
30_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1075,7 +1044,6 @@ it.instance(
|
|||||||
expect((yield* status.get(chat.id)).type).toBe("idle")
|
expect((yield* status.get(chat.id)).type).toBe("idle")
|
||||||
expect((yield* status.get(childID)).type).toBe("idle")
|
expect((yield* status.get(childID)).type).toBe("idle")
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
10_000,
|
10_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1109,9 +1077,7 @@ it.instance(
|
|||||||
|
|
||||||
// Queue semantics
|
// Queue semantics
|
||||||
|
|
||||||
it.instance(
|
it.instance("concurrent loop callers get same result", () =>
|
||||||
"concurrent loop callers get same result",
|
|
||||||
() =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { prompt, run, chat } = yield* boot()
|
const { prompt, run, chat } = yield* boot()
|
||||||
yield* seed(chat.id, { finish: "stop" })
|
yield* seed(chat.id, { finish: "stop" })
|
||||||
@@ -1124,7 +1090,6 @@ it.instance(
|
|||||||
expect(a.info.role).toBe("assistant")
|
expect(a.info.role).toBe("assistant")
|
||||||
yield* run.assertNotBusy(chat.id)
|
yield* run.assertNotBusy(chat.id)
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
it.instance(
|
it.instance(
|
||||||
@@ -1145,7 +1110,6 @@ it.instance(
|
|||||||
expect(a.info.id).toBe(b.info.id)
|
expect(a.info.id).toBe(b.info.id)
|
||||||
expect(a.info.role).toBe("assistant")
|
expect(a.info.role).toBe("assistant")
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
3_000,
|
3_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1214,7 +1178,6 @@ it.instance(
|
|||||||
expect(inputs).toHaveLength(2)
|
expect(inputs).toHaveLength(2)
|
||||||
expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("second")
|
expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("second")
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
3_000,
|
3_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1244,13 +1207,10 @@ it.instance(
|
|||||||
yield* prompt.cancel(chat.id)
|
yield* prompt.cancel(chat.id)
|
||||||
yield* Fiber.await(fiber)
|
yield* Fiber.await(fiber)
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
3_000,
|
3_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
it.instance(
|
it.instance("assertNotBusy succeeds when idle", () =>
|
||||||
"assertNotBusy succeeds when idle",
|
|
||||||
() =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const run = yield* SessionRunState.Service
|
const run = yield* SessionRunState.Service
|
||||||
const sessions = yield* Session.Service
|
const sessions = yield* Session.Service
|
||||||
@@ -1259,7 +1219,6 @@ it.instance(
|
|||||||
const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
|
const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
|
||||||
expect(Exit.isSuccess(exit)).toBe(true)
|
expect(Exit.isSuccess(exit)).toBe(true)
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Shell semantics
|
// Shell semantics
|
||||||
@@ -1288,7 +1247,6 @@ it.instance(
|
|||||||
yield* prompt.cancel(chat.id)
|
yield* prompt.cancel(chat.id)
|
||||||
yield* Fiber.await(fiber)
|
yield* Fiber.await(fiber)
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
3_000,
|
3_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1313,7 +1271,7 @@ unix(
|
|||||||
expect(tool.state.metadata.output).toContain("err")
|
expect(tool.state.metadata.output).toContain("err")
|
||||||
yield* run.assertNotBusy(chat.id)
|
yield* run.assertNotBusy(chat.id)
|
||||||
}),
|
}),
|
||||||
{ git: true, config: cfg },
|
{ config: cfg },
|
||||||
)
|
)
|
||||||
|
|
||||||
unix(
|
unix(
|
||||||
@@ -1337,7 +1295,7 @@ unix(
|
|||||||
expect(tool.state.metadata.output).toContain(dir)
|
expect(tool.state.metadata.output).toContain(dir)
|
||||||
yield* run.assertNotBusy(chat.id)
|
yield* run.assertNotBusy(chat.id)
|
||||||
}),
|
}),
|
||||||
{ git: true, config: cfg },
|
{ config: cfg },
|
||||||
)
|
)
|
||||||
|
|
||||||
unix(
|
unix(
|
||||||
@@ -1359,7 +1317,7 @@ unix(
|
|||||||
expect(tool.state.output).toContain("configured")
|
expect(tool.state.output).toContain("configured")
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
{ git: true, config: { ...cfg, shell: "bash" } },
|
{ config: { ...cfg, shell: "bash" } },
|
||||||
30_000,
|
30_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1384,7 +1342,7 @@ unix(
|
|||||||
expect(tool.state.metadata.output).toContain(parent)
|
expect(tool.state.metadata.output).toContain(parent)
|
||||||
yield* run.assertNotBusy(chat.id)
|
yield* run.assertNotBusy(chat.id)
|
||||||
}),
|
}),
|
||||||
{ git: true, config: cfg },
|
{ config: cfg },
|
||||||
)
|
)
|
||||||
|
|
||||||
unix(
|
unix(
|
||||||
@@ -1410,7 +1368,7 @@ unix(
|
|||||||
expect(tool.state.metadata.output).toContain("README.md")
|
expect(tool.state.metadata.output).toContain("README.md")
|
||||||
yield* run.assertNotBusy(chat.id)
|
yield* run.assertNotBusy(chat.id)
|
||||||
}),
|
}),
|
||||||
{ git: true, config: cfg },
|
{ config: cfg },
|
||||||
)
|
)
|
||||||
|
|
||||||
unix(
|
unix(
|
||||||
@@ -1432,7 +1390,7 @@ unix(
|
|||||||
expect(tool.state.metadata.output).toContain("not found")
|
expect(tool.state.metadata.output).toContain("not found")
|
||||||
yield* run.assertNotBusy(chat.id)
|
yield* run.assertNotBusy(chat.id)
|
||||||
}),
|
}),
|
||||||
{ git: true, config: cfg },
|
{ config: cfg },
|
||||||
)
|
)
|
||||||
|
|
||||||
unix(
|
unix(
|
||||||
@@ -1460,7 +1418,7 @@ unix(
|
|||||||
expect(Exit.isSuccess(exit)).toBe(true)
|
expect(Exit.isSuccess(exit)).toBe(true)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
{ git: true, config: cfg },
|
{ config: cfg },
|
||||||
30_000,
|
30_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1570,7 +1528,6 @@ unix(
|
|||||||
expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("configured")
|
expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("configured")
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
{ git: true },
|
|
||||||
30_000,
|
30_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1806,7 +1763,7 @@ it.instance(
|
|||||||
const exit = yield* Fiber.await(fiber)
|
const exit = yield* Fiber.await(fiber)
|
||||||
expect(Exit.isFailure(exit)).toBe(true)
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
}),
|
}),
|
||||||
{ git: true, config: cfg },
|
{ config: cfg },
|
||||||
30_000,
|
30_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1841,7 +1798,7 @@ it.instance(
|
|||||||
const exit = yield* Fiber.await(fiber)
|
const exit = yield* Fiber.await(fiber)
|
||||||
expect(Exit.isFailure(exit)).toBe(true)
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
}),
|
}),
|
||||||
{ git: true, config: cfg },
|
{ config: cfg },
|
||||||
30_000,
|
30_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1880,7 +1837,7 @@ it.instance(
|
|||||||
|
|
||||||
yield* sessions.remove(session.id)
|
yield* sessions.remove(session.id)
|
||||||
}),
|
}),
|
||||||
{ git: true, config: cfg },
|
{ config: cfg },
|
||||||
)
|
)
|
||||||
|
|
||||||
it.instance(
|
it.instance(
|
||||||
@@ -1922,7 +1879,7 @@ it.instance(
|
|||||||
|
|
||||||
yield* sessions.remove(session.id)
|
yield* sessions.remove(session.id)
|
||||||
}),
|
}),
|
||||||
{ git: true, config: cfg },
|
{ config: cfg },
|
||||||
)
|
)
|
||||||
|
|
||||||
it.instance(
|
it.instance(
|
||||||
@@ -1971,7 +1928,6 @@ it.instance(
|
|||||||
expect(agents.map((agent) => agent.name)).toEqual(["build"])
|
expect(agents.map((agent) => agent.name)).toEqual(["build"])
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
git: true,
|
|
||||||
config: {
|
config: {
|
||||||
...cfg,
|
...cfg,
|
||||||
reference: {
|
reference: {
|
||||||
@@ -2011,7 +1967,6 @@ it.instance(
|
|||||||
yield* sessions.remove(session.id)
|
yield* sessions.remove(session.id)
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
git: true,
|
|
||||||
config: {
|
config: {
|
||||||
...cfg,
|
...cfg,
|
||||||
reference: {
|
reference: {
|
||||||
@@ -2077,7 +2032,6 @@ it.instance(
|
|||||||
yield* sessions.remove(session.id)
|
yield* sessions.remove(session.id)
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
git: true,
|
|
||||||
config: {
|
config: {
|
||||||
...cfg,
|
...cfg,
|
||||||
reference: {
|
reference: {
|
||||||
@@ -2126,9 +2080,7 @@ it.instance(
|
|||||||
|
|
||||||
// Regression: empty assistant turn loop
|
// Regression: empty assistant turn loop
|
||||||
|
|
||||||
it.instance(
|
it.instance("does not loop empty assistant turns for a simple reply", () =>
|
||||||
"does not loop empty assistant turns for a simple reply",
|
|
||||||
() =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { llm } = yield* useServerConfig(providerCfg)
|
const { llm } = yield* useServerConfig(providerCfg)
|
||||||
const prompt = yield* SessionPrompt.Service
|
const prompt = yield* SessionPrompt.Service
|
||||||
@@ -2150,7 +2102,6 @@ it.instance(
|
|||||||
expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1)
|
expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1)
|
||||||
expect(yield* llm.calls).toBe(1)
|
expect(yield* llm.calls).toBe(1)
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
it.instance(
|
it.instance(
|
||||||
@@ -2191,7 +2142,6 @@ it.instance(
|
|||||||
expect(last.info.error?.name).toBe("MessageAbortedError")
|
expect(last.info.error?.name).toBe("MessageAbortedError")
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
3_000,
|
3_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2242,7 +2192,6 @@ it.instance(
|
|||||||
yield* sessions.remove(session.id)
|
yield* sessions.remove(session.id)
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
git: true,
|
|
||||||
config: {
|
config: {
|
||||||
...cfg,
|
...cfg,
|
||||||
provider: {
|
provider: {
|
||||||
@@ -2295,7 +2244,6 @@ it.instance(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
30_000,
|
30_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2324,7 +2272,6 @@ it.instance(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
30_000,
|
30_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2354,6 +2301,5 @@ it.instance(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
|
||||||
30_000,
|
30_000,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|||||||
import { Ripgrep } from "../../src/file/ripgrep"
|
import { Ripgrep } from "../../src/file/ripgrep"
|
||||||
import { Format } from "../../src/format"
|
import { Format } from "../../src/format"
|
||||||
import { Reference } from "../../src/reference/reference"
|
import { Reference } from "../../src/reference/reference"
|
||||||
|
import { RepositoryCache } from "../../src/reference/repository-cache"
|
||||||
import { SyncEvent } from "@/sync"
|
import { SyncEvent } from "@/sync"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||||
@@ -138,6 +139,7 @@ function makeHttp() {
|
|||||||
Layer.provide(Skill.defaultLayer),
|
Layer.provide(Skill.defaultLayer),
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
|
Layer.provide(RepositoryCache.defaultLayer),
|
||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Git.defaultLayer),
|
||||||
Layer.provide(Reference.defaultLayer),
|
Layer.provide(Reference.defaultLayer),
|
||||||
Layer.provide(Ripgrep.defaultLayer),
|
Layer.provide(Ripgrep.defaultLayer),
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { describe, expect, beforeEach, afterAll } from "bun:test"
|
import { describe, expect, beforeEach, afterAll } from "bun:test"
|
||||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Deferred, Effect, Layer, Schema } from "effect"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
|
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
|
||||||
import { SyncEvent } from "../../src/sync"
|
import { SyncEvent } from "../../src/sync"
|
||||||
import { Database, eq } from "@/storage/db"
|
import { Database, eq } from "@/storage/db"
|
||||||
import { EventSequenceTable, EventTable } from "../../src/sync/event.sql"
|
import { EventSequenceTable, EventTable } from "../../src/sync/event.sql"
|
||||||
import { MessageID } from "../../src/session/schema"
|
import { MessageID } from "../../src/session/schema"
|
||||||
import { initProjectors } from "../../src/server/projectors"
|
import { initProjectors } from "../../src/server/projectors"
|
||||||
import { testEffect } from "../lib/effect"
|
import { awaitWithTimeout, testEffect } from "../lib/effect"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
|
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
@@ -139,6 +140,43 @@ describe("SyncEvent", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Regression for the EffectBridge migration. GlobalBus.emit used to fire
|
||||||
|
// synchronously inside the Database.effect post-commit callback. After the
|
||||||
|
// migration it fires inside the forked publish Effect, AFTER bus.publish
|
||||||
|
// completes. Consumers don't care about microsecond-level ordering, but
|
||||||
|
// we still need to prove the emit actually fires.
|
||||||
|
it.live(
|
||||||
|
"emits sync events to GlobalBus after publishing to ProjectBus",
|
||||||
|
provideTmpdirInstance(() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { Created } = setup()
|
||||||
|
// Filter for OUR specific event in the handler so we ignore any
|
||||||
|
// stray sync events from other tests' lingering forks.
|
||||||
|
const received = yield* Deferred.make<GlobalEvent>()
|
||||||
|
const handler = (evt: GlobalEvent) => {
|
||||||
|
if (evt.payload?.type === "sync" && evt.payload?.syncEvent?.type === "item.created.1") {
|
||||||
|
Deferred.doneUnsafe(received, Effect.succeed(evt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GlobalBus.on("event", handler)
|
||||||
|
try {
|
||||||
|
yield* SyncEvent.use.run(Created, { id: "evt_global_1", name: "global" })
|
||||||
|
const event = yield* awaitWithTimeout(
|
||||||
|
Deferred.await(received),
|
||||||
|
"timed out waiting for sync event on GlobalBus",
|
||||||
|
"2 seconds",
|
||||||
|
)
|
||||||
|
expect(event.payload).toMatchObject({
|
||||||
|
type: "sync",
|
||||||
|
syncEvent: { type: "item.created.1", data: { id: "evt_global_1", name: "global" } },
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
GlobalBus.off("event", handler)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("replay", () => {
|
describe("replay", () => {
|
||||||
|
|||||||
@@ -6,22 +6,39 @@ import { SessionID, MessageID } from "../../src/session/schema"
|
|||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { Ripgrep } from "../../src/file/ripgrep"
|
import { Ripgrep } from "../../src/file/ripgrep"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Truncate } from "@/tool/truncate"
|
import { Truncate } from "@/tool/truncate"
|
||||||
import { Agent } from "../../src/agent/agent"
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { TestInstance } from "../fixture/fixture"
|
import { TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { Reference } from "@/reference/reference"
|
import { Reference } from "@/reference/reference"
|
||||||
|
import { RepositoryCache } from "@/reference/repository-cache"
|
||||||
|
import { Config } from "@/config/config"
|
||||||
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
|
import { Git } from "@/git"
|
||||||
|
import { Permission } from "../../src/permission"
|
||||||
|
import type * as Tool from "../../src/tool/tool"
|
||||||
|
|
||||||
const it = testEffect(
|
const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||||
|
Reference.layer.pipe(
|
||||||
|
Layer.provide(Config.defaultLayer),
|
||||||
|
Layer.provide(RepositoryCache.defaultLayer),
|
||||||
|
Layer.provide(RuntimeFlags.layer(flags)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
CrossSpawnSpawner.defaultLayer,
|
CrossSpawnSpawner.defaultLayer,
|
||||||
AppFileSystem.defaultLayer,
|
AppFileSystem.defaultLayer,
|
||||||
Ripgrep.defaultLayer,
|
Ripgrep.defaultLayer,
|
||||||
Truncate.defaultLayer,
|
Truncate.defaultLayer,
|
||||||
Agent.defaultLayer,
|
Agent.defaultLayer,
|
||||||
Reference.defaultLayer,
|
Git.defaultLayer,
|
||||||
),
|
referenceLayer(flags),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const it = testEffect(toolLayer())
|
||||||
|
const scout = testEffect(toolLayer({ experimentalScout: true }))
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
@@ -34,6 +51,52 @@ const ctx = {
|
|||||||
ask: () => Effect.void,
|
ask: () => Effect.void,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const asks = () => {
|
||||||
|
const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
next: {
|
||||||
|
...ctx,
|
||||||
|
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
items.push(req)
|
||||||
|
}),
|
||||||
|
} satisfies Tool.Context,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
|
||||||
|
process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url
|
||||||
|
return previous
|
||||||
|
}),
|
||||||
|
() => self,
|
||||||
|
(previous) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous
|
||||||
|
else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const git = Effect.fn("GlobToolTest.git")(function* (cwd: string, args: string[]) {
|
||||||
|
return yield* Effect.promise(async () => {
|
||||||
|
const proc = Bun.spawn(["git", ...args], {
|
||||||
|
cwd,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
const [stdout, stderr, code] = await Promise.all([
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
proc.exited,
|
||||||
|
])
|
||||||
|
if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`)
|
||||||
|
return stdout.trim()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("tool.glob", () => {
|
describe("tool.glob", () => {
|
||||||
it.instance("matches files from a directory path", () =>
|
it.instance("matches files from a directory path", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -78,4 +141,45 @@ describe("tool.glob", () => {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
scout.instance(
|
||||||
|
"does not ask for external_directory permission inside configured git references",
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* TestInstance
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const cache = path.join(Global.Path.repos, "github.com", "opencode-glob-reference", "repo")
|
||||||
|
yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
|
||||||
|
yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
|
||||||
|
|
||||||
|
const source = yield* tmpdirScoped({ git: true })
|
||||||
|
const remoteRoot = yield* tmpdirScoped()
|
||||||
|
const remoteDir = path.join(remoteRoot, "opencode-glob-reference")
|
||||||
|
const remoteRepo = path.join(remoteDir, "repo.git")
|
||||||
|
yield* fs.writeWithDirs(path.join(source, "src", "index.ts"), "export const value = 1\n")
|
||||||
|
yield* git(source, ["add", "."])
|
||||||
|
yield* git(source, ["commit", "-m", "add source"])
|
||||||
|
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
||||||
|
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
||||||
|
|
||||||
|
const { items, next } = asks()
|
||||||
|
const info = yield* GlobTool
|
||||||
|
const glob = yield* info.init()
|
||||||
|
const result = yield* githubBase(
|
||||||
|
`file://${remoteRoot}/`,
|
||||||
|
glob.execute({ pattern: "*.ts", path: path.join(cache, "src") }, next),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.metadata.count).toBe(1)
|
||||||
|
expect(result.output).toContain(path.join(cache, "src", "index.ts"))
|
||||||
|
expect(items.find((item) => item.permission === "external_directory")).toBeUndefined()
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
reference: {
|
||||||
|
docs: "opencode-glob-reference/repo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,28 +4,44 @@ import os from "os"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { GrepTool } from "../../src/tool/grep"
|
import { GrepTool } from "../../src/tool/grep"
|
||||||
import { provideInstance, TestInstance } from "../fixture/fixture"
|
import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Truncate } from "@/tool/truncate"
|
import { Truncate } from "@/tool/truncate"
|
||||||
import { Agent } from "../../src/agent/agent"
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { Ripgrep } from "../../src/file/ripgrep"
|
import { Ripgrep } from "../../src/file/ripgrep"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { Reference } from "@/reference/reference"
|
import { Reference } from "@/reference/reference"
|
||||||
|
import { RepositoryCache } from "@/reference/repository-cache"
|
||||||
import { Permission } from "../../src/permission"
|
import { Permission } from "../../src/permission"
|
||||||
import type * as Tool from "../../src/tool/tool"
|
import type * as Tool from "../../src/tool/tool"
|
||||||
|
import { Config } from "@/config/config"
|
||||||
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
|
import { Git } from "@/git"
|
||||||
|
import { Filesystem } from "@/util/filesystem"
|
||||||
|
|
||||||
const it = testEffect(
|
const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||||
|
Reference.layer.pipe(
|
||||||
|
Layer.provide(Config.defaultLayer),
|
||||||
|
Layer.provide(RepositoryCache.defaultLayer),
|
||||||
|
Layer.provide(RuntimeFlags.layer(flags)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
CrossSpawnSpawner.defaultLayer,
|
CrossSpawnSpawner.defaultLayer,
|
||||||
AppFileSystem.defaultLayer,
|
AppFileSystem.defaultLayer,
|
||||||
Ripgrep.defaultLayer,
|
Ripgrep.defaultLayer,
|
||||||
Truncate.defaultLayer,
|
Truncate.defaultLayer,
|
||||||
Agent.defaultLayer,
|
Agent.defaultLayer,
|
||||||
Reference.defaultLayer,
|
Git.defaultLayer,
|
||||||
),
|
referenceLayer(flags),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const it = testEffect(toolLayer())
|
||||||
|
const scout = testEffect(toolLayer({ experimentalScout: true }))
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
@@ -39,6 +55,39 @@ const ctx = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const root = path.join(__dirname, "../..")
|
const root = path.join(__dirname, "../..")
|
||||||
|
const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
|
||||||
|
|
||||||
|
const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
|
||||||
|
process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url
|
||||||
|
return previous
|
||||||
|
}),
|
||||||
|
() => self,
|
||||||
|
(previous) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous
|
||||||
|
else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const git = Effect.fn("GrepToolTest.git")(function* (cwd: string, args: string[]) {
|
||||||
|
return yield* Effect.promise(async () => {
|
||||||
|
const proc = Bun.spawn(["git", ...args], {
|
||||||
|
cwd,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
const [stdout, stderr, code] = await Promise.all([
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
proc.exited,
|
||||||
|
])
|
||||||
|
if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`)
|
||||||
|
return stdout.trim()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("tool.grep", () => {
|
describe("tool.grep", () => {
|
||||||
it.live("basic search", () =>
|
it.live("basic search", () =>
|
||||||
@@ -163,4 +212,53 @@ describe("tool.grep", () => {
|
|||||||
expect(requests.find((req) => req.permission === "external_directory")).toBeUndefined()
|
expect(requests.find((req) => req.permission === "external_directory")).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
scout.instance(
|
||||||
|
"does not ask for external_directory permission inside configured git references",
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* TestInstance
|
||||||
|
const appfs = yield* AppFileSystem.Service
|
||||||
|
const cache = path.join(Global.Path.repos, "github.com", "opencode-grep-reference", "repo")
|
||||||
|
yield* appfs.remove(cache, { recursive: true }).pipe(Effect.ignore)
|
||||||
|
yield* Effect.addFinalizer(() => appfs.remove(cache, { recursive: true }).pipe(Effect.ignore))
|
||||||
|
|
||||||
|
const source = yield* tmpdirScoped({ git: true })
|
||||||
|
const remoteRoot = yield* tmpdirScoped()
|
||||||
|
const remoteDir = path.join(remoteRoot, "opencode-grep-reference")
|
||||||
|
const remoteRepo = path.join(remoteDir, "repo.git")
|
||||||
|
yield* appfs.writeWithDirs(path.join(source, "src", "notes.md"), "needle\n")
|
||||||
|
yield* git(source, ["add", "."])
|
||||||
|
yield* git(source, ["commit", "-m", "add notes"])
|
||||||
|
yield* appfs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
||||||
|
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
||||||
|
|
||||||
|
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||||
|
const next: Tool.Context = {
|
||||||
|
...ctx,
|
||||||
|
ask: (req) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
requests.push(req)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = yield* GrepTool
|
||||||
|
const grep = yield* info.init()
|
||||||
|
const result = yield* githubBase(
|
||||||
|
`file://${remoteRoot}/`,
|
||||||
|
grep.execute({ pattern: "needle", path: path.join(cache, "src"), include: "*.md" }, next),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.metadata.matches).toBe(1)
|
||||||
|
expect(full(result.output)).toContain(full(path.join(cache, "src", "notes.md")))
|
||||||
|
expect(requests.find((req) => req.permission === "external_directory")).toBeUndefined()
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
reference: {
|
||||||
|
docs: "opencode-grep-reference/repo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user