Compare commits

..

46 Commits

Author SHA1 Message Date
Kit Langton 1d2e75ef49 fix(config): guard env-var JSON parsing against invalid input 2026-05-18 20:42:31 -04:00
opencode-agent[bot] ce09fc8356 chore: generate 2026-05-18 22:03:00 +00:00
Luke Parker 44a35c5895 test(app): add session timeline smoke coverage (#26619) 2026-05-18 22:01:29 +00:00
Kit Langton 8a321c4536 fix(native-llm): prefer console opencode token (#28237) 2026-05-18 17:23:57 -04:00
opencode ef9e567e5f sync release versions for v1.15.5 2026-05-18 20:57:13 +00:00
Kit Langton b396b71c6f fix(ui): guard reasoning renderer against undefined text (#28222) 2026-05-18 16:04:07 -04:00
Aiden Cline d8efc575fa refactor(session): extract prompt tool resolution (#28204) 2026-05-18 14:50:31 -05:00
opencode-agent[bot] e1fbed8fb6 chore: update nix node_modules hashes 2026-05-18 19:12:18 +00:00
James Long 12ae22378f fix(plugin): ask in tools from plugins returns promise instead of effect (#28217) 2026-05-18 18:57:29 +00:00
opencode-agent[bot] a88b436eec chore: generate 2026-05-18 18:42:55 +00:00
Kit Langton dbe36851bc Preview native LLM runtime stack (#27114) 2026-05-18 14:41:36 -04:00
James Long ff9d7cab5c fix(core): fix file references in workspaces (#28209) 2026-05-18 18:23:35 +00:00
Kit Langton 88681d389b Migrate provider lookup tests to instance fixtures
Migrate provider env precedence, model lookup, and default model tests to Effect-aware instance fixtures while keeping behavior unchanged.
2026-05-18 18:01:53 +00:00
Kit Langton ae2ecd1ed3 Migrate custom provider tests to instance fixtures
Migrate the next custom provider/model config tests to Effect-aware instance fixtures while keeping timing neutral and behavior unchanged.
2026-05-18 17:49:26 +00:00
Kit Langton 159d271e1e refactor(sync): publish via EffectBridge.fork for codebase consistency (#28187) 2026-05-18 13:30:41 -04:00
Kit Langton 762850dfe5 Migrate provider config tests to instance fixtures
Convert the first provider env/config/filtering tests to Effect-aware instance fixtures while keeping behavior unchanged and documenting neutral timing.
2026-05-18 13:22:59 -04:00
Kit Langton b039702e8c feat(tui): add syntax highlighting for elixir, fsharp, r, make, vim, xml, agda (#28198) 2026-05-18 13:08:08 -04:00
opencode-agent[bot] 094886c848 chore: generate 2026-05-18 17:03:44 +00:00
Shoubhit Dash dc0297f4ac refactor(session): extract reference prompt helpers (#28197) 2026-05-18 22:32:18 +05:30
Kit Langton 3ab67f3280 Stabilize watcher test readiness (#28194) 2026-05-18 12:51:46 -04:00
Shoubhit Dash a2e6bd503b refactor(reference): split materialization state (#28190) 2026-05-18 22:14:33 +05:30
Aiden Cline 05ac345696 refactor(session): move prompt reminders out of core loop (#28082) 2026-05-18 11:36:38 -05:00
opencode-agent[bot] e3feca09b3 chore: generate 2026-05-18 16:20:46 +00:00
Kit Langton 896ad7b884 Speed up targeted opencode tests
Reduce avoidable setup costs in slow opencode tests while preserving reviewed coverage and recording the benchmark evidence for follow-up test-suite work.
2026-05-18 16:18:29 +00:00
opencode-agent[bot] 0a945219a9 chore: generate 2026-05-18 16:17:46 +00:00
Shoubhit Dash 94828eb44b refactor(repository): type cache failures (#28188) 2026-05-18 21:46:18 +05:30
Shoubhit Dash 96192495ae refactor(repository): add cache service (#28184) 2026-05-18 21:25:38 +05:30
opencode-agent[bot] f7b5576bcc chore: generate 2026-05-18 15:39:33 +00:00
Kit Langton cb35493242 fix(bus): acquire PubSub subscription eagerly to close /event race (#27959) 2026-05-18 11:38:05 -04:00
Shoubhit Dash 5bfd7fd16c refactor(repository): clarify reference domain (#28182) 2026-05-18 21:04:44 +05:30
opencode-agent[bot] 2932b41e64 chore: generate 2026-05-18 15:14:13 +00:00
Shoubhit Dash eb389c58eb refactor(reference): normalize config entries (#28178) 2026-05-18 20:42:41 +05:30
Shoubhit Dash 54ff0a669b test(reference): cover configured reference contracts (#28170) 2026-05-18 20:09:27 +05:30
opencode-agent[bot] e56999fd36 chore: generate 2026-05-18 12:31:37 +00:00
Simon Klee 1124315267 run: refresh prompt layout after paste (#28164)
Pasting into the prompt textarea left its layout stale until the next edit, so the visible content did not reflect the pasted text. Mark the layout dirty on paste and notify the content-change handler once the renderer is idle so the prompt updates immediately.
2026-05-18 12:30:20 +00:00
opencode-agent[bot] 2bf3f3041f chore: update nix node_modules hashes 2026-05-18 12:03:51 +00:00
Sebastian 6e4db5666a upgrade opentui to 0.2.14 (#28090) 2026-05-18 13:44:08 +02:00
Shoubhit Dash 564cde393e fix(tui): copy pasted prompt content (#28156) 2026-05-18 17:02:34 +05:30
opencode-agent[bot] c813927bb6 chore: generate 2026-05-18 11:07:52 +00:00
Simon Klee 5970c12d90 run: replay session history on interactive resume (#26880) 2026-05-18 13:06:27 +02:00
opencode-agent[bot] 116a4e33ba chore: generate 2026-05-18 11:06:05 +00:00
Shoubhit Dash 611e48c4ac fix(tui): collapse long tool output lines (#28148) 2026-05-18 16:34:34 +05:30
Brendan Allan 836a33198e fix(ui): fix question dock overflow and message part flex layout (#28142) 2026-05-18 17:55:27 +08:00
opencode-agent[bot] 7afd477d1a chore: generate 2026-05-18 09:28:20 +00:00
SpiritChen51 fe143df151 fix(ui): fallback to execCommand for clipboard copy when navigator.clipboard fails (#27993)
Co-authored-by: SpiritChen51 <spiritchen51@users.noreply.github.com>
2026-05-18 17:26:45 +08:00
Kagura e94aecaa08 fix(tui): use contrast-aware foreground for paste summary badge (#27969)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-05-18 08:38:40 +00:00
127 changed files with 8302 additions and 2520 deletions
+34 -32
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.15.4",
"version": "1.15.5",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -84,7 +84,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.15.4",
"version": "1.15.5",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -119,7 +119,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.15.4",
"version": "1.15.5",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -146,7 +146,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.15.4",
"version": "1.15.5",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.15.4",
"version": "1.15.5",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.15.4",
"version": "1.15.5",
"bin": {
"opencode": "./bin/opencode",
},
@@ -253,7 +253,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.15.4",
"version": "1.15.5",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -307,7 +307,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.15.4",
"version": "1.15.5",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -337,7 +337,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.15.4",
"version": "1.15.5",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -353,7 +353,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.15.4",
"version": "1.15.5",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -366,7 +366,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.15.4",
"version": "1.15.5",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -384,7 +384,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.15.4",
"version": "1.15.5",
"bin": {
"opencode": "./bin/opencode",
},
@@ -421,6 +421,7 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
@@ -489,6 +490,7 @@
"@babel/core": "7.28.4",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/http-recorder": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
@@ -520,7 +522,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.15.4",
"version": "1.15.5",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -536,9 +538,9 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.2.13",
"@opentui/keymap": ">=0.2.13",
"@opentui/solid": ">=0.2.13",
"@opentui/core": ">=0.2.14",
"@opentui/keymap": ">=0.2.14",
"@opentui/solid": ">=0.2.14",
},
"optionalPeers": [
"@opentui/core",
@@ -558,7 +560,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.15.4",
"version": "1.15.5",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -573,7 +575,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.15.4",
"version": "1.15.5",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -608,7 +610,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.15.4",
"version": "1.15.5",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -657,7 +659,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.15.4",
"version": "1.15.5",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -721,9 +723,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.13",
"@opentui/keymap": "0.2.13",
"@opentui/solid": "0.2.13",
"@opentui/core": "0.2.14",
"@opentui/keymap": "0.2.14",
"@opentui/solid": "0.2.14",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -1590,23 +1592,23 @@
"@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=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-LN6VLX8bXADSPGt67+YjP1Sy0QdQY+HFPq8iXN5nkck=",
"aarch64-linux": "sha256-3yhpjuCLwi7KCZZvsfabtc50hgznVfD8rcMZ9/JAnSs=",
"aarch64-darwin": "sha256-6spcWVHVBuM0SlWYTFrDvForGbtxFXxeVvJMC9thN+g=",
"x86_64-darwin": "sha256-hnD68Dexq/3EP/Sm3MnL88ezIvLLa6/nVzaV+bRpn9w="
"x86_64-linux": "sha256-FI1mX42vJuYdUDdWevlfHz+OcYkDn/I/HUbHE/jdQvs=",
"aarch64-linux": "sha256-3CQzzKnh/4Zf5vyn56yR5P3ULsW7K7Fr8/RQpekEJDk=",
"aarch64-darwin": "sha256-XPDVHMxlPpXlf43BRqNnwF809unk6iE8tvd0o92d0/w=",
"x86_64-darwin": "sha256-dFXTi13RSgL62lMsep1EoE/KSEPF7Oh31PVdxW1tkzg="
}
}
+3 -3
View File
@@ -35,9 +35,9 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.13",
"@opentui/keymap": "0.2.13",
"@opentui/solid": "0.2.13",
"@opentui/core": "0.2.14",
"@opentui/keymap": "0.2.14",
"@opentui/solid": "0.2.14",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@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()
}
-11
View File
@@ -1,11 +0,0 @@
import { test } from "@playwright/test"
test(
"test something cool",
{
annotation: { type: "todo" },
},
async () => {
test.fixme()
},
)
+18
View File
@@ -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: [],
})
}
+86
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.15.4",
"version": "1.15.5",
"description": "",
"type": "module",
"exports": {
@@ -469,7 +469,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</>
}
>
<div data-slot="question-text">{question()?.question}</div>
<div data-slot="question-text" class="overflow-auto">
{question()?.question}
</div>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.15.4",
"version": "1.15.5",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.15.4",
"version": "1.15.5",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.15.4",
"version": "1.15.5",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.15.4",
"version": "1.15.5",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.4",
"version": "1.15.5",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.15.4",
"version": "1.15.5",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.15.4",
"version": "1.15.5",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.15.4"
version = "1.15.5"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[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"
args = ["acp"]
[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"
args = ["acp"]
[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"
args = ["acp"]
[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"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.15.4",
"version": "1.15.5",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.4",
"version": "1.15.5",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.4",
"version": "1.15.5",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+2
View File
@@ -91,6 +91,7 @@ export const TextDelta = Schema.Struct({
type: Schema.tag("text-delta"),
id: ContentBlockID,
text: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextDelta" })
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
@@ -112,6 +113,7 @@ export const ReasoningDelta = Schema.Struct({
type: Schema.tag("reasoning-delta"),
id: ContentBlockID,
text: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningDelta" })
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
+6 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.4",
"version": "1.15.5",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -10,6 +10,8 @@
"test": "bun test --timeout 30000",
"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",
"bench:test": "bun run script/bench-test-suite.ts",
"profile:test": "bun run script/profile-test-files.ts",
"build": "bun run script/build.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"dev": "bun run --conditions=browser ./src/index.ts",
@@ -37,8 +39,9 @@
"devDependencies": {
"@babel/core": "7.28.4",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/http-recorder": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
@@ -99,6 +102,7 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
+76
View File
@@ -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)
+29 -21
View File
@@ -37,8 +37,16 @@ export interface Interface {
properties: BusProperties<D>,
options?: { id?: string },
) => Effect.Effect<void>
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
readonly subscribeAll: () => Stream.Stream<Payload>
// subscribe / subscribeAll are eager: the underlying PubSub subscription is
// 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>(
def: D,
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>> {
log.info("subscribing", { type: def.type })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return Stream.fromPubSub(ps)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
}
const subscribe = <D extends BusEvent.Definition>(
def: D,
): Effect.Effect<Stream.Stream<Payload<D>>, never, Scope.Scope> =>
Effect.gen(function* () {
log.info("subscribing", { type: def.type })
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
const subscription = yield* PubSub.subscribe(ps)
yield* Effect.addFinalizer(() => Effect.sync(() => log.info("unsubscribing", { type: def.type })))
return Stream.fromSubscription(subscription)
})
function subscribeAll(): Stream.Stream<Payload> {
log.info("subscribing", { type: "*" })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
return Stream.fromPubSub(s.wildcard)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
}
const subscribeAll = (): Effect.Effect<Stream.Stream<Payload>, never, Scope.Scope> =>
Effect.gen(function* () {
log.info("subscribing", { type: "*" })
const s = yield* InstanceState.get(state)
const subscription = yield* PubSub.subscribe(s.wildcard)
yield* Effect.addFinalizer(() => Effect.sync(() => log.info("unsubscribing", { type: "*" })))
return Stream.fromSubscription(subscription)
})
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
return Effect.gen(function* () {
+30
View File
@@ -218,6 +218,15 @@ export const RunCommand = effectCmd({
type: "boolean",
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", {
alias: ["i"],
type: "boolean",
@@ -269,6 +278,21 @@ export const RunCommand = effectCmd({
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) {
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 directory = (() => {
if (!args.dir) return args.attach ? undefined : root
@@ -786,6 +812,8 @@ export const RunCommand = effectCmd({
sessionID,
sessionTitle: sess.title,
resume: Boolean(args.session || args.continue) && !args.fork,
replay,
replayLimit: args["replay-limit"],
agent,
model,
variant: args.variant,
@@ -821,6 +849,8 @@ export const RunCommand = effectCmd({
agent: args.agent,
model,
variant: args.variant,
replay,
replayLimit: args["replay-limit"],
files,
initialInput,
thinking,
@@ -7,7 +7,7 @@
/** @jsxImportSource @opentui/solid */
import { pathToFileURL } from "bun"
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 path from "path"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
@@ -197,13 +197,45 @@ export function RunPromptBody(props: {
onContentChange: () => void
bind: (area?: TextareaRenderable) => void
}) {
const renderer = useRenderer()
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(() => {
props.bind(area)
})
onCleanup(() => {
if (pasteTick) {
clearTimeout(pasteTick)
}
props.bind(undefined)
})
@@ -226,6 +258,9 @@ export function RunPromptBody(props: {
keyBindings={props.bindings()}
onSubmit={props.onSubmit}
onKeyDown={props.onKeyDown}
onPaste={() => {
refreshPasteLayout()
}}
onContentChange={props.onContentChange}
ref={(next) => {
area = next
@@ -51,6 +51,8 @@ type RunRuntimeInput = {
files: RunInput["files"]
initialInput?: string
thinking: boolean
replay?: boolean
replayLimit?: number
demo?: RunInput["demo"]
}
@@ -67,6 +69,8 @@ type RunLocalInput = {
files: RunInput["files"]
initialInput?: string
thinking: boolean
replay?: boolean
replayLimit?: number
demo?: RunInput["demo"]
}
@@ -490,6 +494,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
directory: ctx.directory,
sessionID: state.sessionID,
thinking: input.thinking,
replay: input.replay,
replayLimit: input.replayLimit,
limits: () => state.limits,
footer,
trace: log,
@@ -722,6 +728,8 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
replay: input.replay,
replayLimit: input.replayLimit,
demo: input.demo,
resolveSession: () => {
if (session) {
@@ -774,6 +782,8 @@ export async function runInteractiveMode(input: RunInput & { createSession?: Cre
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
replay: input.replay,
replayLimit: input.replayLimit,
demo: input.demo,
boot: async () => ({
sdk: input.sdk,
@@ -360,10 +360,14 @@ function syncPermission(data: SessionData, part: ToolPart): FooterOutput | undef
}
}
// Tool-owned question requests can complete without a matching question.replied
// event. When that happens, drop the recovered pending request tied to this tool
// call so the footer can return to the next blocker or to the prompt.
// Question tool replies can complete without a matching question.replied event.
// When that happens, drop the recovered pending request tied to this tool call so
// the footer can return to the next blocker or to the prompt.
function syncQuestion(data: SessionData, part: ToolPart): FooterOutput | undefined {
if (part.tool !== "question") {
return undefined
}
if (part.state.status !== "completed" && part.state.status !== "error") {
return undefined
}
@@ -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"] = []
let text = msg.parts
.filter((part): part is Extract<SessionMessages[number]["parts"][number], { type: "text" }> => {
@@ -135,7 +135,7 @@ function turn(msg: SessionMessages[number]): Turn | undefined {
}
return {
prompt: prompt(msg),
prompt: messagePrompt(msg),
provider: msg.info.model.providerID,
model: msg.info.model.modelID,
variant: msg.info.model.variant,
@@ -15,7 +15,7 @@
// The tick counter prevents stale idle events from resolving the wrong turn.
// We also re-check live session status before resolving an idle event so a
// delayed idle from an older turn cannot complete a newer busy turn.
import type { Event, GlobalEvent, OpencodeClient, ToolPart } from "@opencode-ai/sdk/v2"
import type { Event, GlobalEvent, OpencodeClient } from "@opencode-ai/sdk/v2"
import { Context, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect"
import { makeRuntime } from "@/effect/run-service"
import {
@@ -27,6 +27,7 @@ import {
reduceSessionData,
type SessionData,
} from "./session-data"
import { replaySession } from "./session-replay"
import {
bootstrapSubagentCalls,
bootstrapSubagentData,
@@ -66,6 +67,8 @@ type StreamInput = {
directory?: string
sessionID: string
thinking: boolean
replay?: boolean
replayLimit?: number
limits: () => Record<string, number>
footer: FooterApi
trace?: Trace
@@ -432,7 +435,12 @@ function createLayer(input: StreamInput) {
blockerTick: 0,
blockers: new Map(),
}
let booting = true
const buffered: Event[] = []
const replayedParts = new Set<string>()
const recovering = new Set<string>()
const tracked = (sessionID: string | undefined) =>
sessionID === input.sessionID || (!!sessionID && state.subagent.tabs.has(sessionID))
const currentSubagentState = () => {
if (state.selectedSubagent && !state.subagent.tabs.has(state.selectedSubagent)) {
state.selectedSubagent = undefined
@@ -505,10 +513,7 @@ function createLayer(input: StreamInput) {
state.footerView = current
}
const recoverQuestion = Effect.fn("RunStreamTransport.recoverQuestion")(function* (part: ToolPart) {
const partID = part.id
const matches = (request: SessionData["questions"][number]) =>
request.tool?.messageID === part.messageID && request.tool?.callID === part.callID
const recoverQuestion = Effect.fn("RunStreamTransport.recoverQuestion")(function* (partID: string) {
if (recovering.has(partID)) {
return
}
@@ -516,7 +521,7 @@ function createLayer(input: StreamInput) {
recovering.add(partID)
try {
while (!closed && !abort.signal.aborted && !input.footer.isClosed) {
if (state.data.questions.some(matches) || !state.data.tools.has(partID)) {
if (state.data.questions.length > 0 || !state.data.tools.has(partID)) {
return
}
@@ -524,14 +529,11 @@ function createLayer(input: StreamInput) {
Effect.map((item) => (item.data ?? []).filter((request) => request.sessionID === input.sessionID)),
Effect.orElseSucceed(() => []),
)
if (state.data.questions.some(matches) || !state.data.tools.has(partID)) {
if (state.data.questions.length > 0 || !state.data.tools.has(partID)) {
return
}
const matching = questions.filter(matches)
if (matching.length > 0) {
const active = new Set(questions.map((request) => request.id))
state.data.questions = state.data.questions.filter((request) => active.has(request.id))
if (questions.length > 0) {
bootstrapSessionData({
data: state.data,
messages: [],
@@ -541,11 +543,9 @@ function createLayer(input: StreamInput) {
for (const request of questions) {
seedBlocker(request.id)
}
const priority = Math.min(0, ...state.blockers.values()) - 1
for (const request of matching) state.blockers.set(request.id, priority)
input.trace?.write("question.recover", {
sessionID: input.sessionID,
requests: matching.map((request) => request.id),
requests: questions.map((request) => request.id),
})
syncFooter([])
return
@@ -558,11 +558,11 @@ function createLayer(input: StreamInput) {
}
})
const messages = (sessionID: string, limit: number) =>
const messages = (sessionID: string, limit?: number) =>
Effect.promise(() =>
input.sdk.session.messages({
sessionID,
limit,
...(typeof limit === "number" ? { limit } : {}),
}),
).pipe(
Effect.map((item) => item.data ?? []),
@@ -604,7 +604,14 @@ function createLayer(input: StreamInput) {
const bootstrap = Effect.fn("RunStreamTransport.bootstrap")(function* () {
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(() =>
input.sdk.session.children({
sessionID: input.sessionID,
@@ -627,12 +634,52 @@ function createLayer(input: StreamInput) {
},
)
bootstrapSessionData({
data: state.data,
messages: messagesList,
permissions: permissions.filter((item) => item.sessionID === input.sessionID),
questions: questions.filter((item) => item.sessionID === input.sessionID),
})
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({
data: state.data,
messages: messagesList,
permissions: sessionPermissions,
questions: sessionQuestions,
})
}
if (replay) {
for (const [partID] of replay.data.text) {
if (!replay.data.part.has(partID)) {
continue
}
replayedParts.add(partID)
}
}
bootstrapSubagentData({
data: state.subagent,
messages: messagesList,
@@ -640,6 +687,7 @@ function createLayer(input: StreamInput) {
permissions,
questions,
})
clearFinishedSubagents(state.subagent)
for (const request of [
...state.data.permissions,
@@ -650,9 +698,29 @@ function createLayer(input: StreamInput) {
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()
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()]
if (sessions.length === 0) {
@@ -746,6 +814,86 @@ function createLayer(input: StreamInput) {
})
}
const applyEvent = Effect.fn("RunStreamTransport.applyEvent")(function* (event: Event) {
if (event.type === "message.part.delta" && event.properties.sessionID === input.sessionID) {
if (replayedParts.has(event.properties.partID)) {
const seen = state.data.text.get(event.properties.partID) ?? ""
if (seen.endsWith(event.properties.delta)) {
return
}
replayedParts.delete(event.properties.partID)
}
}
trackBlocker(event)
const prev = event.type === "message.part.updated" ? listSubagentTabs(state.subagent) : undefined
const next = reduceSessionData({
data: state.data,
event,
sessionID: input.sessionID,
thinking: input.thinking,
limits: input.limits(),
})
state.data = next.data
if (
event.type === "message.part.updated" &&
event.properties.part.sessionID === input.sessionID &&
event.properties.part.type === "tool" &&
event.properties.part.tool === "question" &&
event.properties.part.state.status === "running" &&
state.data.questions.length === 0
) {
yield* recoverQuestion(event.properties.part.id).pipe(
Effect.forkIn(scope, { startImmediately: true }),
Effect.asVoid,
)
}
const changed = reduceSubagentData({
data: state.subagent,
event,
sessionID: input.sessionID,
thinking: input.thinking,
limits: input.limits(),
})
if (changed && prev) {
traceTabs(input.trace, prev, listSubagentTabs(state.subagent))
}
releaseBlocker(event)
syncFooter(next.commits, next.footer?.patch, changed ? currentSubagentState() : undefined)
touch(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)),
@@ -770,52 +918,25 @@ function createLayer(input: StreamInput) {
}
const sessionID = sid(event)
if (sessionID !== input.sessionID && (!sessionID || !state.subagent.tabs.has(sessionID))) {
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)
trackBlocker(event)
const prev = event.type === "message.part.updated" ? listSubagentTabs(state.subagent) : undefined
const next = reduceSessionData({
data: state.data,
event,
sessionID: input.sessionID,
thinking: input.thinking,
limits: input.limits(),
})
state.data = next.data
if (
event.type === "message.part.updated" &&
event.properties.part.sessionID === input.sessionID &&
event.properties.part.type === "tool" &&
(event.properties.part.tool === "question" || event.properties.part.tool === "plan_exit") &&
event.properties.part.state.status === "running"
) {
yield* recoverQuestion(event.properties.part).pipe(
Effect.forkIn(scope, { startImmediately: true }),
Effect.asVoid,
)
}
const changed = reduceSubagentData({
data: state.subagent,
event,
sessionID: input.sessionID,
thinking: input.thinking,
limits: input.limits(),
})
if (changed && prev) {
traceTabs(input.trace, prev, listSubagentTabs(state.subagent))
}
releaseBlocker(event)
syncFooter(next.commits, next.footer?.patch, changed ? currentSubagentState() : undefined)
touch(event)
yield* mark(event)
yield* applyEvent(event)
yield* drainBuffered()
}),
),
Effect.catch((error) => (abort.signal.aborted ? Effect.void : fail(error))),
@@ -830,8 +951,8 @@ function createLayer(input: StreamInput) {
),
)
yield* bootstrap()
yield* Scope.provide(scope)(watch().pipe(Effect.forkScoped))
yield* bootstrap()
const runPromptTurn = Effect.fn("RunStreamTransport.runPromptTurn")(function* (next: SessionTurnInput) {
if (closed || next.signal?.aborted || input.footer.isClosed) {
@@ -420,9 +420,26 @@ function ensureBlockerTab(
title: string | undefined,
kind: "permission" | "question",
) {
if (data.tabs.has(sessionID)) {
const current = data.tabs.get(sessionID)
if (current) {
ensureDetail(data, sessionID)
return false
if (current.status !== "running") {
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, {
@@ -52,6 +52,8 @@ export type RunInput = {
sessionID: string
sessionTitle?: string
resume?: boolean
replay?: boolean
replayLimit?: number
agent: string | undefined
model: PromptModel | undefined
variant: string | undefined
@@ -1,12 +1,14 @@
import { createMemo, createResource } from "solid-js"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { useProject } from "@tui/context/project"
import { useSDK } from "@tui/context/sdk"
import { createStore } from "solid-js/store"
export function DialogTag(props: { onSelect?: (value: string) => void }) {
const sdk = useSDK()
const dialog = useDialog()
const project = useProject()
const [store] = createStore({
filter: "",
@@ -17,6 +19,7 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) {
async () => {
const result = await sdk.client.find.files({
query: store.filter,
workspace: project.workspace.current(),
})
if (result.error) return []
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 { createStore } from "solid-js/store"
import { useEditorContext } from "@tui/context/editor"
import { useProject } from "@tui/context/project"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
import { getScrollAcceleration } from "../../util/scroll"
@@ -19,7 +20,7 @@ import type { PromptInfo } from "./history"
import { useFrecency } from "./frecency"
import { useBindings } from "../../keymap"
import { Reference } from "@/reference/reference"
import type { Config } from "@/config/config"
import { ConfigReference } from "@/config/reference"
import { displayCharAt, mentionTriggerIndex } from "@/cli/cmd/prompt-display"
function removeLineRange(input: string) {
@@ -85,6 +86,7 @@ export function Autocomplete(props: {
const editor = useEditorContext()
const sdk = useSDK()
const sync = useSync()
const project = useProject()
const command = useCommandPalette()
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
@@ -310,7 +312,7 @@ export function Autocomplete(props: {
`Referenced configured reference @${reference.name}.`,
...(reference.kind === "local" ? ["Kind: local directory"] : []),
...(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" && reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
...(reference.kind === "invalid" ? [] : [`Reference root: ${reference.path}`]),
@@ -324,7 +326,7 @@ export function Autocomplete(props: {
const references = createMemo(() =>
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(),
worktree: sync.path.worktree || sync.path.directory || process.cwd(),
}),
@@ -382,6 +384,7 @@ export function Autocomplete(props: {
// Get files from SDK
const result = await sdk.client.find.files({
query: baseQuery,
workspace: project.workspace.current(),
})
const options: AutocompleteOption[] = []
@@ -28,7 +28,7 @@ import { MessageID, PartID } from "@/session/schema"
import { createStore, produce, unwrap } from "solid-js/store"
import { usePromptHistory, type PromptInfo } from "./history"
import { computePromptTraits } from "./traits"
import { assign } from "./part"
import { assign, expandPastedTextPlaceholders } from "./part"
import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
@@ -1544,6 +1544,9 @@ export function Prompt(props: PromptProps) {
}}
ref={(r: TextareaRenderable) => {
input = r
Object.assign(r, {
getClipboardText: (text: string) => expandPastedTextPlaceholders(text, store.prompt.parts),
})
setInputTarget(r)
if (promptPartTypeId === 0) {
promptPartTypeId = input.extmarks.registerType("prompt-part")
@@ -14,3 +14,10 @@ export function assign(part: Item): Item & { id: PartID } {
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)
}
@@ -1,6 +1,8 @@
import { createStore, reconcile } from "solid-js/store"
import { Option, Schema } from "effect"
import { createSimpleContext } from "./helper"
import type { PromptInfo } from "../component/prompt/history"
import { tryJsonConfig } from "@/util/json"
export type HomeRoute = {
type: "home"
@@ -21,17 +23,25 @@ export type PluginRoute = {
export type Route = HomeRoute | SessionRoute | PluginRoute
const HOME_ROUTE: Route = { type: "home" }
// Schema covers only the env-fed shape — `prompt` is set programmatically and
// not expected from OPENCODE_ROUTE, so we keep it out of validation.
export const RouteSchema = Schema.Union([
Schema.Struct({ type: Schema.Literal("home") }),
Schema.Struct({ type: Schema.Literal("session"), sessionID: Schema.String }),
Schema.Struct({
type: Schema.Literal("plugin"),
id: Schema.String,
data: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}),
])
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
init: (props: { initialRoute?: Route }) => {
const [store, setStore] = createStore<Route>(
props.initialRoute ??
(process.env["OPENCODE_ROUTE"]
? JSON.parse(process.env["OPENCODE_ROUTE"])
: {
type: "home",
}),
)
const fromEnv = tryJsonConfig(process.env["OPENCODE_ROUTE"], RouteSchema, "OPENCODE_ROUTE")
const [store, setStore] = createStore<Route>(props.initialRoute ?? Option.getOrElse(fromEnv, () => HOME_ROUTE))
return {
get data() {
@@ -34,14 +34,6 @@ import path from "path"
import { useKV } from "./kv"
import { aggregateFailures } from "./aggregate-failures"
export function questionToolRequestIndex(requests: readonly QuestionRequest[] | undefined, part: Part) {
if (part.type !== "tool") return -1
if (part.state.status !== "completed" && part.state.status !== "error") return -1
return requests?.findIndex(
(request) => request.tool?.messageID === part.messageID && request.tool?.callID === part.callID,
) ?? -1
}
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
@@ -312,32 +304,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
case "message.part.updated": {
const part = event.properties.part
const parts = store.part[part.messageID]
const parts = store.part[event.properties.part.messageID]
if (!parts) {
setStore("part", part.messageID, [part])
setStore("part", event.properties.part.messageID, [event.properties.part])
break
}
if (parts) {
const result = Binary.search(parts, part.id, (p) => p.id)
if (result.found) setStore("part", part.messageID, result.index, reconcile(part))
if (!result.found)
setStore(
"part",
part.messageID,
produce((draft) => {
draft.splice(result.index, 0, part)
}),
)
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
if (result.found) {
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
break
}
const index = questionToolRequestIndex(store.question[part.sessionID], part)
if (index !== -1)
setStore(
"question",
part.sessionID,
produce((draft) => {
draft.splice(index, 1)
}),
)
setStore(
"part",
event.properties.part.messageID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.part)
}),
)
break
}
@@ -773,7 +773,7 @@ function getSyntaxRules(theme: Theme) {
{
scope: ["extmark.paste"],
style: {
foreground: theme.background,
foreground: selectedForeground(theme, theme.warning),
background: theme.warning,
bold: true,
},
@@ -30,6 +30,7 @@ import type {
ToolTextContent,
} from "@opencode-ai/sdk/v2"
import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
import { collapseToolOutput } from "../../util/collapse-tool-output"
const id = "internal:session-v2-debug"
const route = "session.v2.messages"
@@ -198,26 +199,28 @@ function UserMessage(props: { message: SessionMessageUser; index: number }) {
function ShellMessage(props: { message: SessionMessageShell }) {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const output = createMemo(() => stripAnsi(props.message.output.trim()))
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, 10), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
return (
<BlockTool
title="# Shell"
spinner={!props.message.time.completed}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {props.message.command}</text>
<Show when={output()}>
<text fg={theme.text}>{limited()}</text>
</Show>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -518,14 +521,15 @@ type ToolProps = {
function GenericTool(props: ToolProps) {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const output = createMemo(() => props.output?.trim() ?? "")
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const maxLines = 3
const overflow = createMemo(() => lines().length > maxLines)
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, maxLines), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
return (
<Show
@@ -539,11 +543,11 @@ function GenericTool(props: ToolProps) {
<BlockTool
title={`# ${props.part.name} ${input(props.input)}`}
part={props.part}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>{limited()}</text>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -702,15 +706,17 @@ function BlockTool(props: {
function Bash(props: ToolProps) {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const output = createMemo(() => stripAnsi((stringValue(props.metadata.output) ?? props.output ?? "").trim()))
const command = createMemo(() => stringValue(props.input.command) ?? pendingInput(props.part))
const title = createMemo(() => `# ${stringValue(props.input.description) ?? "Shell"}`)
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, 10), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
return (
<Switch>
@@ -719,12 +725,12 @@ function Bash(props: ToolProps) {
title={title()}
part={props.part}
spinner={props.part.state.status === "running"}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {command()}</text>
<text fg={theme.text}>{limited()}</text>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -114,6 +114,7 @@ type RuntimeState = {
plugins: PluginEntry[]
plugins_by_id: Map<string, PluginEntry>
pending: Map<string, ConfigPlugin.Origin>
dispose_timeout_ms: number
}
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()
let list: { key: symbol; fn: TuiDispose }[] = []
let done = false
@@ -436,14 +437,14 @@ function createPluginScope(load: PluginLoad, id: string) {
ctrl.abort()
const queue = [...list].reverse()
list = []
const until = Date.now() + DISPOSE_TIMEOUT_MS
const until = Date.now() + disposeTimeoutMs
for (const item of queue) {
const left = until - Date.now()
if (left <= 0) {
fail("timed out cleaning up tui plugin", {
path: load.spec,
id,
timeout: DISPOSE_TIMEOUT_MS,
timeout: disposeTimeoutMs,
})
break
}
@@ -454,7 +455,7 @@ function createPluginScope(load: PluginLoad, id: string) {
fail("timed out cleaning up tui plugin", {
path: load.spec,
id,
timeout: DISPOSE_TIMEOUT_MS,
timeout: disposeTimeoutMs,
})
break
}
@@ -523,7 +524,7 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per
if (persist) writePluginEnabledState(state.api, plugin.id, 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 ok = await Promise.resolve()
.then(async () => {
@@ -1002,7 +1003,12 @@ let loaded: Promise<void> | undefined
let runtime: RuntimeState | undefined
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()
if (loaded) {
if (dir !== cwd) {
@@ -1052,7 +1058,7 @@ export async function 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 cwd = process.cwd()
const slots = setupSlots(api)
@@ -1064,6 +1070,7 @@ async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: ()
plugins: [],
plugins_by_id: new Map(),
pending: new Map(),
dispose_timeout_ms: input.disposeTimeoutMs ?? DISPOSE_TIMEOUT_MS,
}
runtime = next
try {
@@ -84,6 +84,7 @@ import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
import { nextThinkingMode, reasoningTitle, useThinkingMode, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
import { collapseToolOutput } from "../../util/collapse-tool-output"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { DialogRetryAction } from "../../component/dialog-retry-action"
import { SessionRetry } from "@/session/retry"
@@ -1696,12 +1697,12 @@ function GenericTool(props: ToolProps<any>) {
const ctx = use()
const output = createMemo(() => props.output?.trim() ?? "")
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const maxLines = 3
const overflow = createMemo(() => lines().length > maxLines)
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, maxLines), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
return (
@@ -1716,11 +1717,11 @@ function GenericTool(props: ToolProps<any>) {
<BlockTool
title={`# ${props.tool} ${input(props.input)}`}
part={props.part}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>{limited()}</text>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -1871,14 +1872,16 @@ function BlockTool(props: {
function Shell(props: ToolProps<typeof ShellTool>) {
const { theme } = useTheme()
const pathFormatter = usePathFormatter()
const ctx = use()
const isRunning = createMemo(() => props.part.state.status === "running")
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, 10), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
const workdirDisplay = createMemo(() => {
@@ -1902,14 +1905,14 @@ function Shell(props: ToolProps<typeof ShellTool>) {
title={title()}
part={props.part}
spinner={isRunning()}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {props.input.command}</text>
<Show when={output()}>
<text fg={theme.text}>{limited()}</text>
</Show>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -0,0 +1,19 @@
export function collapseToolOutput(output: string, maxLines: number, maxChars: number) {
const lines = output.split("\n")
if (lines.length <= maxLines && Array.from(output).length <= maxChars) {
return { output, overflow: false }
}
const preview = lines.slice(0, maxLines).join("\n")
if (Array.from(preview).length > maxChars) {
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 }
}
@@ -7,6 +7,7 @@ type Toast = {
type FocusableSelectionTarget = {
hasSelection: () => boolean
getClipboardText?: (text: string) => string
}
type Renderer = {
@@ -23,10 +24,17 @@ type SelectionKeyEvent = {
}
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
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" }))
.catch(toast.error)
+15
View File
@@ -106,6 +106,21 @@ export function FormatError(input: unknown): string | undefined {
].join("\n")
}
// InvalidConfigError: malformed env-var JSON at a safety boundary
if (isTaggedError(input, "InvalidConfigError")) {
const source = stringField(input, "source") ?? ""
const value = stringField(input, "value") ?? ""
const reason = stringField(input, "reason") ?? ""
return [
`Error: ${source} is invalid and cannot be applied.`,
` Value: '${value}'`,
` Reason: ${reason}`,
"",
`opencode will not start with a malformed ${source}. Set ${source}`,
`correctly or unset it to use defaults.`,
].join("\n")
}
// UICancelledError: user cancelled an interactive CLI prompt
if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) {
return ""
+3 -1
View File
@@ -14,6 +14,7 @@ import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/instal
import { existsSync } from "fs"
import { Account } from "@/account/account"
import { isRecord } from "@/util/record"
import { requireJsonConfig } from "@/util/json"
import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { InstanceState } from "@/effect/instance-state"
@@ -703,7 +704,8 @@ export const layer = Layer.effect(
}
if (Flag.OPENCODE_PERMISSION) {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
const decoded = requireJsonConfig(Flag.OPENCODE_PERMISSION, ConfigPermission.Info, "OPENCODE_PERMISSION")
result.permission = mergeDeep(result.permission ?? {}, decoded)
}
if (result.tools) {
+46
View File
@@ -18,6 +18,52 @@ const Local = Schema.Struct({
})
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 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,
state: Record<string, number>,
signal?: AbortSignal,
timeout?: number,
) => Effect.Effect<void, WaitForSyncError>
readonly startWorkspaceSyncing: (projectID: ProjectID) => Effect.Effect<void>
}
@@ -946,12 +947,13 @@ export const layer = Layer.effect(
workspaceID: WorkspaceID,
state: Record<string, number>,
signal?: AbortSignal,
timeout = TIMEOUT,
) {
if (synced(state)) return
yield* Effect.catch(
waitEvent({
timeout: TIMEOUT,
timeout,
signal,
fn(event) {
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"),
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
experimentalNativeLlm: enabledByExperimental("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
}) {}
+1 -1
View File
@@ -243,7 +243,7 @@ export const layer = Layer.effect(
}
// Subscribe to bus events, fiber interrupted when scope closes
yield* bus.subscribeAll().pipe(
yield* (yield* bus.subscribeAll()).pipe(
Stream.runForEach((input) =>
Effect.sync(() => {
for (const hook of hooks) {
+1 -1
View File
@@ -425,7 +425,7 @@ export const layer: Layer.Layer<
const initState = yield* InstanceState.make(
Effect.fn("Project.initState")(function* (ctx) {
yield* bus.subscribe(Command.Event.Executed).pipe(
yield* (yield* bus.subscribe(Command.Event.Executed)).pipe(
Stream.runForEach((payload) =>
payload.properties.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void,
),
+1 -1
View File
@@ -298,7 +298,7 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
const value = { current, root }
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.runForEach((_evt) =>
Effect.gen(function* () {
+73 -75
View File
@@ -3,14 +3,12 @@ import { Effect, Context, Layer, Scope } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { Config } from "@/config/config"
import { ConfigReference } from "@/config/reference"
import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Git } from "@/git"
import { parseRepositoryReference, repositoryCachePath, type Reference as RepositoryReference } from "@/util/repository"
import { parseRepositoryReference, repositoryCachePath, type RemoteReference } from "@/util/repository"
import { RepositoryCache } from "./repository-cache"
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
export type Resolved =
| {
name: string
@@ -21,23 +19,25 @@ export type Resolved =
name: string
kind: "git"
repository: string
reference: RepositoryReference
reference: RemoteReference
path: string
branch?: string
}
| {
name: string
kind: "invalid"
repository: string
repository?: string
message: string
}
type State = {
references: Resolved[]
materializeAll: Effect.Effect<void>
materializeByPath: { path: string; run: Effect.Effect<void> }[]
materializeByPath: Materializer[]
}
type Materializer = { path: string; run: Effect.Effect<void> }
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly list: () => Effect.Effect<Resolved[]>
@@ -90,31 +90,75 @@ function containsReferencePath(referencePath: string, target: string) {
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: {
name: string
reference: ReferenceEntry
reference: ConfigReference.NormalizedEntry
directory: string
worktree: string
}): Resolved {
if (typeof input.reference === "string") {
if (input.reference.startsWith(".") || input.reference.startsWith("/") || input.reference.startsWith("~")) {
return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference }) }
}
return resolveGit({ name: input.name, repository: input.reference })
if (input.reference.kind === "invalid") {
return { name: input.name, kind: "invalid", message: input.reference.message }
}
if ("path" in input.reference) {
if (input.reference.kind === "local") {
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 })
}
export function resolveAll(input: {
references: NonNullable<Config.Info["reference"]>
directory: string
worktree: string
}) {
export function resolveAll(input: { references: ConfigReference.NormalizedInfo; directory: string; worktree: string }) {
const seen = new Map<string, { name: string; branch?: string }>()
return Object.entries(input.references).map(([name, reference]) => {
const resolved = resolve({ name, reference, directory: input.directory, worktree: input.worktree })
@@ -140,8 +184,7 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const cache = yield* RepositoryCache.Service
const scope = yield* Scope.Scope
const flags = yield* RuntimeFlags.Service
@@ -149,53 +192,14 @@ export const layer = Layer.effect(
Effect.fn("Reference.state")(function* (ctx) {
const cfg = yield* config.get()
const references = resolveAll({
references: cfg.reference ?? {},
references: ConfigReference.normalize(cfg.reference ?? {}),
directory: ctx.directory,
worktree: ctx.worktree,
})
const seenPath = new Set<string>()
const gitReferences = 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
})
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 materializeByPath = yield* materializers(cache, references)
const materializeAllCached = yield* Effect.cached(materializeAll({ flags, materializers: materializeByPath }))
const materializeAll = yield* Effect.cached(
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 }
return { references, materializeAll: materializeAllCached, materializeByPath }
}),
)
@@ -214,18 +218,13 @@ export const layer = Layer.effect(
if (!flags.experimentalScout) return
const full = normalizedTarget(target)
if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll)
return yield* InstanceState.useEffect(
state,
(s) => s.materializeByPath.find((item) => containsReferencePath(item.path, full))?.run ?? Effect.void,
)
return yield* InstanceState.useEffect(state, (s) => materializeByPath(s.materializeByPath, full))
}),
contains: Effect.fn("Reference.contains")(function* (target?: string) {
if (!flags.experimentalScout) return false
const full = normalizedTarget(target)
if (!full) return false
return yield* InstanceState.use(state, (s) =>
s.references.some((reference) => reference.kind === "git" && containsReferencePath(reference.path, full)),
)
return yield* InstanceState.use(state, (s) => containsGitReferencePath(s.references, full))
}),
})
}),
@@ -233,8 +232,7 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(RepositoryCache.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
)
@@ -1,5 +1,5 @@
import path from "path"
import { Effect } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Flock } from "@opencode-ai/core/util/flock"
import { Git } from "@/git"
@@ -8,7 +8,8 @@ import {
sameRepositoryReference,
parseRepositoryReference,
validateRepositoryBranch,
type Reference as RepositoryReference,
isRemoteRepositoryReference,
type RemoteReference,
} from "@/util/repository"
export type Result = {
@@ -21,6 +22,82 @@ export type Result = {
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 }) {
if (!input.reuse) return "cloned" as const
if (input.branchMatches === false) return "refreshed" as const
@@ -43,18 +120,53 @@ function resetTarget(input: {
return "HEAD"
}
export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
input: {
reference: RepositoryReference
refresh?: boolean
branch?: string
},
function errorMessage(error: unknown) {
return error instanceof globalThis.Error ? error.message : String(error)
}
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: {
fs: AppFileSystem.Interface
git: Git.Interface
},
) {
if (input.branch) validateRepositoryBranch(input.branch)
if (input.branch) yield* validateBranch(input.branch)
const repository = input.reference.label
const remote = input.reference.remote
@@ -62,10 +174,24 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
const cloneTarget = parseRepositoryReference(remote) ?? input.reference
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* () {
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 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 reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget))
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
@@ -91,14 +227,20 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
{ cwd: path.dirname(localPath) },
)
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") {
const fetch = yield* services.git.run(["fetch", "--all", "--prune"], { cwd: localPath })
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) {
@@ -106,9 +248,12 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
cwd: localPath,
})
if (checkout.exitCode !== 0) {
throw new Error(
checkout.stderr.toString().trim() || checkout.text().trim() || `Failed to checkout ${input.branch}`,
)
return yield* new CheckoutFailedError({
repository,
branch: input.branch,
message:
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 })
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"
@@ -20,10 +20,11 @@ function eventData(data: unknown): Sse.Event {
function eventResponse(bus: Bus.Interface) {
return Effect.gen(function* () {
const context = yield* Effect.context()
const events = bus.subscribeAll().pipe(
Stream.provideContext(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
// fiber to drain — closing the race where Stream.concat(server.connected,
// 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),
)
const heartbeat = Stream.tick("10 seconds").pipe(
+156 -83
View File
@@ -2,7 +2,10 @@ import { Provider } from "@/provider/provider"
import * as Log from "@opencode-ai/core/util/log"
import { Context, Effect, Layer, Record } from "effect"
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 { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
import { ProviderTransform } from "@/provider/transform"
@@ -23,10 +26,11 @@ import { EffectBridge } from "@/effect/bridge"
import { RuntimeFlags } from "@/effect/runtime-flags"
import * as Option from "effect/Option"
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" })
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.
const mergeOptions = (target: Record<string, any>, source: Record<string, any> | undefined): Record<string, any> =>
@@ -51,10 +55,8 @@ export type StreamRequest = StreamInput & {
abort: AbortSignal
}
export type Event = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
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") {}
@@ -62,7 +64,13 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/LL
const live: Layer.Layer<
Service,
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(
Service,
Effect.gen(function* () {
@@ -71,6 +79,7 @@ const live: Layer.Layer<
const provider = yield* Provider.Service
const plugin = yield* Plugin.Service
const perm = yield* Permission.Service
const llmClient = yield* LLMClient.Service
const flags = yield* RuntimeFlags.Service
const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
@@ -202,7 +211,7 @@ const live: Layer.Layer<
Object.keys(tools).length === 0 &&
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.",
inputSchema: jsonSchema({
type: "object",
@@ -322,86 +331,141 @@ const live: Layer.Layer<
? (yield* InstanceState.context).project.id
: undefined
return streamText({
onError(error) {
l.error("stream error", {
error,
})
},
async experimental_repairToolCall(failed) {
const lower = failed.toolCall.toolName.toLowerCase()
if (lower !== failed.toolCall.toolName && sortedTools[lower]) {
l.info("repairing tool call", {
tool: failed.toolCall.toolName,
repaired: lower,
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) {
l.error("stream error", {
error,
})
},
async experimental_repairToolCall(failed) {
const lower = failed.toolCall.toolName.toLowerCase()
if (lower !== failed.toolCall.toolName && sortedTools[lower]) {
l.info("repairing tool call", {
tool: failed.toolCall.toolName,
repaired: lower,
})
return {
...failed.toolCall,
toolName: lower,
}
}
return {
...failed.toolCall,
toolName: lower,
}
}
return {
...failed.toolCall,
input: JSON.stringify({
tool: failed.toolCall.toolName,
error: failed.error.message,
}),
toolName: "invalid",
}
},
temperature: params.temperature,
topP: params.topP,
topK: params.topK,
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
activeTools: Object.keys(sortedTools).filter((x) => x !== "invalid"),
tools: sortedTools,
toolChoice: input.toolChoice,
maxOutputTokens: params.maxOutputTokens,
abortSignal: input.abort,
headers: {
...(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: JSON.stringify({
tool: failed.toolCall.toolName,
error: failed.error.message,
}),
...input.model.headers,
...headers,
},
maxRetries: input.retries ?? 0,
messages,
model: wrapLanguageModel({
model: language,
middleware: [
{
specificationVersion: "v3" as const,
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
}
return args.params
},
},
],
}),
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
functionId: "session.llm",
tracer: telemetryTracer,
metadata: {
userId: cfg.username ?? "unknown",
sessionId: input.sessionID,
toolName: "invalid",
}
},
},
})
temperature: params.temperature,
topP: params.topP,
topK: params.topK,
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
activeTools: Object.keys(sortedTools).filter((x) => x !== "invalid"),
tools: sortedTools,
toolChoice: input.toolChoice,
maxOutputTokens: params.maxOutputTokens,
abortSignal: input.abort,
headers: requestHeaders,
maxRetries: input.retries ?? 0,
messages,
model: wrapLanguageModel({
model: language,
middleware: [
{
specificationVersion: "v3" as const,
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
}
return args.params
},
},
],
}),
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
functionId: "session.llm",
tracer: telemetryTracer,
metadata: {
userId: cfg.username ?? "unknown",
sessionId: input.sessionID,
},
},
}),
}
})
const stream: Interface["stream"] = (input) =>
@@ -415,7 +479,15 @@ const live: Layer.Layer<
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(Provider.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.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.
+254
View File
@@ -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"
+166 -107
View File
@@ -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 { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
@@ -9,7 +10,6 @@ import { Snapshot } from "@/snapshot"
import * as Session from "./session"
import { LLM } from "./llm"
import { MessageV2 } from "./message-v2"
import { Image } from "@/image/image"
import { isOverflow } from "./overflow"
import { PartID } 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 * as DateTime from "effect/DateTime"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Usage, type LLMEvent } from "@opencode-ai/llm"
const DOOM_LOOP_THRESHOLD = 3
const log = Log.create({ service: "session.processor" })
export type Result = "compact" | "stop" | "continue"
export type Event = LLM.Event
export interface Handle {
readonly message: MessageV2.Assistant
readonly updateToolCall: (
@@ -69,6 +68,7 @@ type ToolCall = {
messageID: MessageV2.ToolPart["messageID"]
sessionID: MessageV2.ToolPart["sessionID"]
done: Deferred.Deferred<void>
inputEnded: boolean
}
interface ProcessorContext extends Input {
@@ -81,7 +81,7 @@ interface ProcessorContext extends Input {
reasoningMap: Record<string, MessageV2.ReasoningPart>
}
type StreamEvent = Event
type StreamEvent = LLMEvent
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 call = ctx.toolcalls[toolCallID]
if (!call) return
if (!call) return undefined
const part = yield* session.getPart({
partID: call.partID,
messageID: call.messageID,
@@ -145,7 +145,7 @@ export const layer = Layer.effect(
})
if (!part || part.type !== "tool") {
delete ctx.toolcalls[toolCallID]
return
return undefined
}
return { call, part }
})
@@ -155,7 +155,7 @@ export const layer = Layer.effect(
update: (part: MessageV2.ToolPart) => MessageV2.ToolPart,
) {
const match = yield* readToolCall(toolCallID)
if (!match) return
if (!match) return undefined
const part = yield* session.updatePart(update(match.part))
ctx.toolcalls[toolCallID] = {
...match.call,
@@ -211,12 +211,98 @@ export const layer = Layer.effect(
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) {
switch (value.type) {
case "start":
yield* status.set(ctx.sessionID, { type: "busy" })
return
case "reasoning-start":
if (value.id in ctx.reasoningMap) return
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
@@ -240,6 +326,7 @@ export const layer = Layer.effect(
return
case "reasoning-delta":
// Match dev: silently drop orphan deltas (no preceding reasoning-start).
if (!(value.id in ctx.reasoningMap)) return
ctx.reasoningMap[value.id].text += value.text
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
@@ -253,59 +340,26 @@ export const layer = Layer.effect(
return
case "reasoning-end":
if (!(value.id 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: value.id,
text: ctx.reasoningMap[value.id].text,
timestamp: DateTime.makeUnsafe(Date.now()),
})
if (value.providerMetadata && value.id in ctx.reasoningMap) {
ctx.reasoningMap[value.id].metadata = value.providerMetadata
}
// oxlint-disable-next-line no-self-assign -- reactivity trigger
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]
yield* finishReasoning(value.id)
return
case "tool-input-start":
if (ctx.assistantMessage.summary) {
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
}
// 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,
throw new Error(`Tool call not allowed while generating summary: ${value.name}`)
}
yield* ensureToolCall(value)
return
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
case "tool-input-end": {
const toolCall = yield* ensureToolCall(value)
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Tool.Input.Ended, {
@@ -315,37 +369,52 @@ export const layer = Layer.effect(
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
ctx.toolcalls[value.id] = { ...toolCall.call, inputEnded: true }
return
}
case "tool-call": {
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.
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Tool.Called, {
sessionID: ctx.sessionID,
callID: value.toolCallId,
tool: value.toolName,
input: value.input,
callID: value.id,
tool: value.name,
input,
provider: {
executed: toolCall?.part.metadata?.providerExecuted === true,
executed: toolCall.part.metadata?.providerExecuted === true,
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
},
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
yield* updateToolCall(value.toolCallId, (match) => ({
yield* updateToolCall(value.id, (match) => ({
...match,
tool: value.toolName,
state: {
...match.state,
status: "running",
input: value.input,
time: { start: Date.now() },
},
tool: value.name,
state:
match.state.status === "running"
? { ...match.state, input }
: {
status: "running",
input,
time: { start: Date.now() },
},
metadata: match.metadata?.providerExecuted
? { ...value.providerMetadata, providerExecuted: true }
: value.providerMetadata,
@@ -359,9 +428,9 @@ export const layer = Layer.effect(
!recentParts.every(
(part) =>
part.type === "tool" &&
part.tool === value.toolName &&
part.tool === value.name &&
part.state.status !== "pending" &&
JSON.stringify(part.state.input) === JSON.stringify(value.input),
JSON.stringify(part.state.input) === JSON.stringify(input),
)
) {
return
@@ -370,27 +439,19 @@ export const layer = Layer.effect(
const agent = yield* agents.get(ctx.assistantMessage.agent)
yield* permission.ask({
permission: "doom_loop",
patterns: [value.toolName],
patterns: [value.name],
sessionID: ctx.assistantMessage.sessionID,
metadata: { tool: value.toolName, input: value.input },
always: [value.toolName],
metadata: { tool: value.name, input },
always: [value.name],
ruleset: agent.permission,
})
return
}
case "tool-result": {
const toolCall = yield* readToolCall(value.toolCallId)
const toolAttachments: MessageV2.FilePart[] = (
Array.isArray(value.output.attachments) ? value.output.attachments : []
).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) =>
const toolCall = yield* readToolCall(value.id)
const rawOutput = toolResultOutput(value)
const normalized = yield* Effect.forEach(rawOutput.attachments ?? [], (attachment) =>
attachment.mime.startsWith("image/")
? image.normalize(attachment).pipe(
Effect.catchIf(
@@ -404,18 +465,18 @@ export const layer = Layer.effect(
const omitted = normalized.filter(Exit.isFailure).length
const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value)
const output = {
...value.output,
...rawOutput,
output:
omitted === 0
? value.output.output
: `${value.output.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the image size limit.]`,
attachments: attachments?.length ? attachments : undefined,
? rawOutput.output
: `${rawOutput.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the image size limit.]`,
attachments: attachments.length ? attachments : undefined,
}
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Tool.Success, {
sessionID: ctx.sessionID,
callID: value.toolCallId,
callID: value.id,
structured: output.metadata,
content: [
{
@@ -423,32 +484,32 @@ export const layer = Layer.effect(
text: output.output,
},
...(output.attachments?.map((item: MessageV2.FilePart) => ({
type: "file",
type: "file" as const,
uri: item.url,
mime: item.mime,
name: item.filename,
})) ?? []),
],
provider: {
executed: toolCall?.part.metadata?.providerExecuted === true,
executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,
},
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
yield* completeToolCall(value.toolCallId, output)
yield* completeToolCall(value.id, output)
return
}
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.
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: ctx.sessionID,
callID: value.toolCallId,
callID: value.id,
error: {
type: "unknown",
message: errorMessage(value.error),
message: value.message,
},
provider: {
executed: toolCall?.part.metadata?.providerExecuted === true,
@@ -456,14 +517,14 @@ export const layer = Layer.effect(
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
yield* failToolCall(value.toolCallId, value.error)
yield* failToolCall(value.id, value.error ?? new Error(value.message))
return
}
case "error":
throw value.error
case "provider-error":
throw new Error(value.message)
case "start-step":
case "step-start":
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
if (!ctx.assistantMessage.summary) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
@@ -490,11 +551,12 @@ export const layer = Layer.effect(
})
return
case "finish-step": {
case "step-finish": {
const completedSnapshot = yield* snapshot.track()
yield* Effect.forEach(Object.keys(ctx.reasoningMap), finishReasoning)
const usage = Session.getUsage({
model: ctx.model,
usage: value.usage,
usage: value.usage ?? new Usage({}),
metadata: value.providerMetadata,
})
if (!ctx.assistantMessage.summary) {
@@ -502,7 +564,7 @@ export const layer = Layer.effect(
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Step.Ended, {
sessionID: ctx.sessionID,
finish: value.finishReason,
finish: value.reason,
cost: usage.cost,
tokens: usage.tokens,
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.tokens = usage.tokens
yield* session.updatePart({
id: PartID.ascending(),
reason: value.finishReason,
reason: value.reason,
snapshot: completedSnapshot,
messageID: ctx.assistantMessage.id,
sessionID: ctx.assistantMessage.sessionID,
@@ -622,10 +684,6 @@ export const layer = Layer.effect(
case "finish":
return
default:
slog.info("unhandled", { event: value.type, value })
return
}
})
@@ -727,6 +785,7 @@ export const layer = Layer.effect(
yield* Effect.gen(function* () {
ctx.currentText = undefined
ctx.reasoningMap = {}
yield* status.set(ctx.sessionID, { type: "busy" })
const stream = llm.stream(streamInput)
yield* stream.pipe(
+21 -413
View File
@@ -8,19 +8,15 @@ import * as Session from "./session"
import { Agent } from "../agent/agent"
import { Provider } from "@/provider/provider"
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 { SessionCompaction } from "./compaction"
import { Bus } from "../bus"
import { ProviderTransform } from "@/provider/transform"
import { SystemPrompt } from "./system"
import { Instruction } from "./instruction"
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 { ToolRegistry } from "@/tool/registry"
import { ToolJsonSchema } from "@/tool/json-schema"
import { MCP } from "../mcp"
import { LSP } from "@/lsp/lsp"
import { ulid } from "ulid"
@@ -50,7 +46,6 @@ import * as EffectLogger from "@opencode-ai/core/effect/logger"
import { InstanceState } from "@/effect/instance-state"
import { TaskTool, type TaskPromptOps } from "@/tool/task"
import { SessionRunState } from "./run-state"
import { EffectBridge } from "@/effect/bridge"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2 } from "@opencode-ai/core/event"
import { EventV2Bridge } from "@/event-v2-bridge"
@@ -63,6 +58,10 @@ import * as DateTime from "effect/DateTime"
import { eq } from "@/storage/db"
import * as Database from "@/storage/db"
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
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 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 {
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
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 events = yield* EventV2Bridge.Service
const flags = yield* RuntimeFlags.Service
const runner = Effect.fn("SessionPrompt.runner")(function* () {
return yield* EffectBridge.make()
})
const ops = Effect.fn("SessionPrompt.ops")(function* () {
return {
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],
})
.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.mkString,
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) })))
})
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: {
task: MessageV2.SubtaskPart
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 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 = {
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 lastUserMsg = msgs.findLast((m) => m.info.role === "user")
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
const promptOps = yield* ops()
const tools = yield* resolveTools({
const tools = yield* SessionTools.resolve({
agent,
session,
model,
tools: lastUser.tools,
processor: handle,
bypassAgentCheck,
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") {
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"
+6 -7
View File
@@ -4,7 +4,8 @@ import { BackgroundJob } from "@/background/job"
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
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 { 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")
}
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) => {
if (!Number.isFinite(value)) return 0
return Math.max(0, value)
}
const inputTokens = safe(input.usage.inputTokens ?? 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(
input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0,
)
const cacheReadInputTokens = safe(input.usage.cacheReadInputTokens ?? 0)
const cacheWriteInputTokens = safe(
Number(
input.usage.inputTokenDetails?.cacheWriteTokens ??
input.usage.cacheWriteInputTokens ??
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// google-vertex-anthropic returns metadata under "vertex" key
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
+208
View File
@@ -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"
+11 -7
View File
@@ -168,16 +168,20 @@ export const layer = Layer.effect(
fn: (evt: { properties: any }) => Effect.Effect<void, unknown>,
) =>
bus.subscribe(def as never).pipe(
Stream.runForEach((evt) =>
fn(evt).pipe(
Effect.catchCause((cause) =>
Effect.sync(() => {
log.error("share subscriber failed", { type: def.type, cause })
}),
Effect.flatMap((stream) =>
stream.pipe(
Stream.runForEach((evt) =>
fn(evt).pipe(
Effect.catchCause((cause) =>
Effect.sync(() => {
log.error("share subscriber failed", { type: def.type, cause })
}),
),
),
),
Effect.forkScoped,
),
),
Effect.forkScoped,
)
yield* watch(Session.Event.Updated, (evt) =>
+39 -53
View File
@@ -7,9 +7,7 @@ import { eq } from "drizzle-orm"
import { GlobalBus } from "@/bus/global"
import { Bus as ProjectBus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import type { InstanceContext } from "@/project/instance-context"
import { EventSequenceTable, EventTable } from "./event.sql"
import type { WorkspaceID } from "@/control-plane/schema"
import { EventID } from "./schema"
import { Context, Effect, Layer, Schema as EffectSchema } from "effect"
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 { InstanceState } from "@/effect/instance-state"
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
// 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 ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise<unknown>
type PublishContext = {
instance?: InstanceContext
workspace?: WorkspaceID
}
export interface Interface {
readonly run: <Def extends Definition>(
@@ -107,16 +101,14 @@ export const layer = Layer.effect(Service)(
}
const publish = !!options?.publish
const context = publish
? {
instance: yield* InstanceState.context,
workspace: yield* InstanceState.workspaceID,
}
: undefined
// Bridge captures handler-fiber refs (InstanceRef/WorkspaceRef) and the
// full Effect context, so the forked publish + GlobalBus emit run with
// the right state without a per-call attachWith.
const bridge = yield* EffectBridge.make()
process(def, event, {
bus,
bridge,
publish,
context,
ownerID: options?.ownerID,
experimentalWorkspaces: flags.experimentalWorkspaces,
})
@@ -154,12 +146,7 @@ export const layer = Layer.effect(Service)(
}
const { publish = true } = options || {}
const context = publish
? {
instance: yield* InstanceState.context,
workspace: yield* InstanceState.workspaceID,
}
: undefined
const bridge = yield* EffectBridge.make()
// Note that this is an "immediate" transaction which is critical.
// 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 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",
@@ -308,8 +295,8 @@ function process<Def extends Definition>(
event: Event<Def>,
options: {
bus: ProjectBus.Interface
bridge: EffectBridge.Shape
publish: boolean
context?: PublishContext
ownerID?: string
experimentalWorkspaces: boolean
},
@@ -351,37 +338,36 @@ function process<Def extends Definition>(
}
Database.effect(() => {
if (options?.publish) {
if (!options.context?.instance) {
throw new Error("SyncEvent.process: publish requires instance context")
}
const result = convertEvent(def.type, event.data)
const publish = (data: unknown) =>
Effect.runPromise(
attachWith(options.bus.publish(def, data as Properties<Def>, { id: event.id }), {
instance: options.context?.instance,
workspace: options.context?.workspace,
}),
)
if (result instanceof Promise) {
void result.then(publish)
} else {
void publish(result)
}
GlobalBus.emit("event", {
directory: options.context.instance.directory,
project: options.context.instance.project.id,
workspace: options.context.workspace,
payload: {
type: "sync",
syncEvent: {
type: versionedType(def.type, def.version),
...event,
},
},
})
if (!options.publish) return
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) =>
options.bridge.fork(
Effect.gen(function* () {
yield* options.bus.publish(def, data as Properties<Def>, { id: event.id })
const instance = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
GlobalBus.emit("event", {
directory: instance.directory,
project: instance.project.id,
workspace,
payload: {
type: "sync",
syncEvent: {
type: versionedType(def.type, def.version),
...event,
},
},
})
}),
)
if (result instanceof Promise) {
void result.then(publish)
} else {
publish(result)
}
})
})
+8 -2
View File
@@ -25,6 +25,7 @@ import { ProviderID, type ModelID } from "../provider/schema"
import { WebSearchTool } from "./websearch"
import { RepoCloneTool } from "./repo_clone"
import { RepoOverviewTool } from "./repo_overview"
import { RepositoryCache } from "@/reference/repository-cache"
import * as Log from "@opencode-ai/core/util/log"
import { LspTool } from "./lsp"
import * as Truncate from "./truncate"
@@ -39,6 +40,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Ripgrep } from "../file/ripgrep"
import { Format } from "../format"
import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect/bridge"
import { Question } from "../question"
import { Todo } from "../session/todo"
import { LSP } from "@/lsp/lsp"
@@ -93,6 +95,7 @@ export const layer: Layer.Layer<
| BackgroundJob.Service
| Provider.Service
| Git.Service
| RepositoryCache.Service
| Reference.Service
| LSP.Service
| Instruction.Service
@@ -156,9 +159,12 @@ export const layer: Layer.Layer<
description: def.description,
execute: (args, toolCtx) =>
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 = {
...toolCtx,
ask: (req) => toolCtx.ask(req),
ask: (req) => bridge.promise(toolCtx.ask(req)),
directory: ctx.directory,
worktree: ctx.worktree,
}
@@ -378,7 +384,7 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Session.defaultLayer),
Layer.provide(Layer.mergeAll(SessionStatus.defaultLayer, BackgroundJob.defaultLayer)),
Layer.provide(Provider.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(Layer.mergeAll(Git.defaultLayer, RepositoryCache.defaultLayer)),
Layer.provide(Reference.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(Instruction.defaultLayer),
+10 -13
View File
@@ -1,9 +1,7 @@
import { Effect, Schema } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Git } from "@/git"
import DESCRIPTION from "./repo_clone.txt"
import * as Tool from "./tool"
import { parseRemoteRepositoryReference, repositoryCachePath, validateRepositoryBranch } from "@/util/repository"
import { repositoryCachePath } from "@/util/repository"
import { RepositoryCache } from "@/reference/repository-cache"
export const Parameters = Schema.Struct({
@@ -28,19 +26,18 @@ type Metadata = {
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",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const cache = yield* RepositoryCache.Service
return {
description: DESCRIPTION,
parameters: Parameters,
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
Effect.gen(function* () {
const reference = parseRemoteRepositoryReference(params.repository)
if (params.branch) validateRepositoryBranch(params.branch)
const reference = yield* RepositoryCache.parseRemoteReference(params.repository)
if (params.branch) yield* RepositoryCache.validateBranch(params.branch)
const repository = reference.label
const remote = reference.remote
@@ -59,10 +56,7 @@ export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, AppFileSys
},
})
const result = yield* RepositoryCache.ensure(
{ reference, refresh: params.refresh, branch: params.branch },
{ fs, git },
)
const result = yield* cache.ensure({ reference, refresh: params.refresh, branch: params.branch })
return {
title: repository,
metadata: result,
@@ -74,7 +68,10 @@ export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, AppFileSys
...(result.head ? [`HEAD: ${result.head}`] : []),
].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>
}),
)
+49
View File
@@ -0,0 +1,49 @@
import { Option, Result, Schema } from "effect"
import * as Log from "@opencode-ai/core/util/log"
const log = Log.create({ service: "config" })
const decodeJsonUnknown = Schema.decodeUnknownResult(Schema.UnknownFromJsonString)
export class InvalidConfigError extends Schema.TaggedErrorClass<InvalidConfigError>()("InvalidConfigError", {
source: Schema.String,
value: Schema.String,
reason: Schema.String,
}) {}
// Decode JSON config at a safety boundary. On malformed JSON or schema
// mismatch, throws `InvalidConfigError`. On success, returns the typed value.
// Use when defaulting silently is dangerous (e.g. permissions).
export function requireJsonConfig<S extends Schema.Decoder<unknown>>(raw: string, schema: S, source: string): S["Type"] {
const decoded = decode(raw, schema)
if (Result.isFailure(decoded)) {
throw new InvalidConfigError({ source, value: raw, reason: decoded.failure })
}
return decoded.success
}
// Decode JSON config at a UX-preference boundary. Returns `None` for absent,
// empty, malformed, or schema-mismatched input, logging a warn so the user
// can debug. Use when defaulting silently is benign (e.g. theme, route).
export function tryJsonConfig<S extends Schema.Decoder<unknown>>(
raw: string | undefined,
schema: S,
source: string,
): Option.Option<S["Type"]> {
if (!raw) return Option.none()
const decoded = decode(raw, schema)
if (Result.isFailure(decoded)) {
log.warn(`ignoring invalid ${source}`, { value: raw, reason: decoded.failure })
return Option.none()
}
return Option.some(decoded.success)
}
function decode<S extends Schema.Decoder<unknown>>(raw: string, schema: S): Result.Result<S["Type"], string> {
const decodeSchema = Schema.decodeUnknownResult(schema)
const parsed = decodeJsonUnknown(raw)
if (Result.isFailure(parsed)) return Result.fail(parsed.failure.toString())
const decoded = decodeSchema(parsed.success)
if (Result.isFailure(decoded)) return Result.fail(decoded.failure.toString())
return Result.succeed(decoded.success)
}
+41 -17
View File
@@ -2,7 +2,7 @@ import path from "path"
import { fileURLToPath } from "url"
import { Global } from "@opencode-ai/core/global"
export type Reference = {
type BaseReference = {
host: string
path: string
segments: string[]
@@ -10,10 +10,20 @@ export type Reference = {
repo: string
remote: string
label: string
}
export type RemoteReference = BaseReference & {
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
.trim()
.replace(/^git\+/, "")
@@ -54,7 +64,7 @@ function githubRemote(pathname: string) {
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)
if (!safeHost(input.host) || !segments.length || segments.some((segment) => !safeSegment(segment))) return null
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`),
label: host === "github.com" && segments.length === 2 ? pathname : `${host}/${pathname}`,
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 segments = filePath.split(/[\\/]+/).filter(Boolean)
if (!segments.length) return null
@@ -85,36 +95,38 @@ function buildFile(input: { url: URL; remote: string }) {
remote: input.remote,
label: filePath,
protocol: "file:",
} satisfies Reference
} satisfies FileReference
}
export function parseRepositoryReference(input: string) {
const cleaned = normalize(input)
const cleaned = normalizeRepositoryInput(input)
if (!cleaned) return null
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("://")) {
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)
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) {
return build({ host: "github.com", segments: direct })
return buildRemoteReference({ host: "github.com", segments: direct })
}
}
try {
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 host = url.host
return build({
return buildRemoteReference({
host,
segments: pathname,
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) {
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.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
}
@@ -141,7 +161,7 @@ export function validateRepositoryBranch(branch: string) {
}
export function parseGitHubRemote(input: string) {
const cleaned = normalize(input)
const cleaned = normalizeRepositoryInput(input)
if (!cleaned.includes("://") && !cleaned.match(/^(?:[^@/\s]+@)?github\.com:/)) return null
const parsed = parseRepositoryReference(cleaned)
@@ -153,6 +173,10 @@ export function repositoryCachePath(input: Reference) {
return path.join(Global.Path.repos, ...input.host.split(":"), ...input.segments)
}
export function sameRepositoryReference(left: Reference, right: Reference) {
return left.host === right.host && left.path === right.path
export function repositoryCacheIdentity(input: Reference) {
return `${input.host}/${input.path}`
}
export function sameRepositoryReference(left: Reference, right: Reference) {
return repositoryCacheIdentity(left) === repositoryCacheIdentity(right)
}
+92 -6
View File
@@ -44,7 +44,7 @@ describe("Bus (Effect-native)", () => {
const done = yield* Deferred.make<void>()
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* () {
if (evt.properties.value < 0) {
yield* ready.open
@@ -71,7 +71,7 @@ describe("Bus (Effect-native)", () => {
const done = yield* Deferred.make<void>()
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* () {
if (evt.properties.value < 0) {
yield* ready.open
@@ -98,7 +98,7 @@ describe("Bus (Effect-native)", () => {
const done = yield* Deferred.make<void>()
const ready = yield* Latch.make()
yield* Stream.runForEach(bus.subscribeAll(), (evt) =>
yield* Stream.runForEach(yield* bus.subscribeAll(), (evt) =>
Effect.gen(function* () {
if (evt.type === TestEvent.Warmup.type) {
yield* ready.open
@@ -129,7 +129,7 @@ describe("Bus (Effect-native)", () => {
const readyA = 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* () {
if (evt.properties.value < 0) {
yield* readyA.open
@@ -140,7 +140,7 @@ describe("Bus (Effect-native)", () => {
}),
).pipe(Effect.forkScoped)
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) =>
Effect.gen(function* () {
if (evt.properties.value < 0) {
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", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
@@ -174,7 +260,7 @@ describe("Bus (Effect-native)", () => {
yield* Effect.gen(function* () {
const bus = yield* Bus.Service
yield* Stream.runForEach(bus.subscribeAll(), (evt) =>
yield* Stream.runForEach(yield* bus.subscribeAll(), (evt) =>
Effect.gen(function* () {
if (evt.type === TestEvent.Warmup.type) {
yield* ready.open
@@ -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 {
return {
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 () => {
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
}
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) {
return {
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 {
id: `evt-${partID}-delta`,
type: "message.part.delta",
properties: {
sessionID: "session-1",
sessionID,
messageID,
partID,
field: "text",
@@ -331,6 +347,7 @@ function footer(fn?: (commit: StreamCommit) => void) {
const commits: StreamCommit[] = []
const events: FooterEvent[] = []
let closed = false
let idleCalls = 0
const api: FooterApi = {
get isClosed() {
@@ -346,6 +363,7 @@ function footer(fn?: (commit: StreamCommit) => void) {
fn?.(next)
},
idle() {
idleCalls += 1
return Promise.resolve()
},
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(
@@ -398,6 +423,355 @@ function sdk(
}
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 () => {
const src = eventFeed()
const ui = footer()
@@ -487,7 +861,7 @@ describe("run stream transport", () => {
expect.objectContaining({
sessionID: "child-1",
label: "Explore",
description: "Explore run folder",
description: "Pending permission",
status: "running",
}),
])
@@ -569,7 +943,7 @@ describe("run stream transport", () => {
sessionID: "session-1",
id: "msg-1",
parts: [
completedTool({
runningTool({
sessionID: "session-1",
messageID: "msg-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 () => {
const global = globalFeed()
const ui = footer()
@@ -835,17 +1312,12 @@ describe("run stream transport", () => {
callID: "call-question-1",
},
}
const other = {
...request,
id: "question-old",
tool: { messageID: "msg-old", callID: "call-question-old" },
}
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
questions: async () => {
questionCalls += 1
return ok(questionCalls === 1 ? [other] : [request])
return ok(questionCalls > 1 ? [request] : [])
},
promptAsync: async () => {
queueMicrotask(() => {
@@ -890,9 +1362,7 @@ describe("run stream transport", () => {
const view = await waitFor(() => {
const item = ui.events.findLast((event) => event.type === "stream.view")
return item?.type === "stream.view" && item.view.type === "question" && item.view.request.id === request.id
? item.view
: undefined
return item?.type === "stream.view" && item.view.type === "question" ? item.view : undefined
})
expect(view).toEqual({
@@ -908,7 +1378,6 @@ describe("run stream transport", () => {
},
})
const count = ui.events.length
src.push(
toolUpdated(
completedTool({
@@ -938,121 +1407,6 @@ describe("run stream transport", () => {
view: { type: "prompt" },
})
expect(
ui.events.slice(count).findLast(
(event) => event.type === "stream.view" && event.view.type === "question" && event.view.request.id === other.id,
),
).toBeUndefined()
ctrl.abort()
await run
} finally {
src.close()
await transport.close()
}
})
test("recovers pending plan_exit questions from question.list when question.asked is missed", async () => {
const src = eventFeed()
const ui = footer()
let questionCalls = 0
const request = {
id: "question-plan-1",
sessionID: "session-1",
questions: [
{
question: "Plan is complete. Start implementing it now?",
header: "Build Agent",
options: [{ label: "Yes", description: "Switch to build agent and start implementing." }],
multiple: false,
},
],
tool: {
messageID: "msg-plan-1",
callID: "call-plan-exit-1",
},
}
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
questions: async () => {
questionCalls += 1
return ok(questionCalls === 1 ? [] : [request])
},
promptAsync: async () => {
queueMicrotask(() => {
src.push(busy())
src.push(assistant("msg-plan-1"))
src.push(
toolUpdated(
runningTool({
sessionID: "session-1",
messageID: "msg-plan-1",
id: "plan-exit-tool-1",
callID: "call-plan-exit-1",
tool: "plan_exit",
body: {},
}),
),
)
})
return ok(undefined)
},
}),
sessionID: "session-1",
thinking: true,
limits: () => ({}),
footer: ui.api,
})
const ctrl = new AbortController()
try {
const run = transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { text: "hello", parts: [] },
files: [],
includeFiles: false,
signal: ctrl.signal,
})
expect(
await waitFor(() => {
const item = ui.events.findLast((event) => event.type === "stream.view")
return item?.type === "stream.view" && item.view.type === "question" ? item.view : undefined
}),
).toEqual({
type: "question",
request,
})
src.push(
toolUpdated(
completedTool({
sessionID: "session-1",
messageID: "msg-plan-1",
id: "plan-exit-tool-1",
callID: "call-plan-exit-1",
tool: "plan_exit",
body: {},
output: "User approved switching to build agent.",
metadata: {},
}),
),
)
expect(
await waitFor(() => {
const item = ui.events.findLast((event) => event.type === "stream.view")
return item?.type === "stream.view" && item.view.type === "prompt" ? item : undefined
}),
).toEqual({
type: "stream.view",
view: { type: "prompt" },
})
ctrl.abort()
await run
} finally {
@@ -205,10 +205,10 @@ test(
const { config, restore } = mockTuiRuntime(tmp.path, [tmp.extra.spec])
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config, disposeTimeoutMs: 25 })
const done = await new Promise<string>((resolve) => {
const timer = setTimeout(() => resolve("timeout"), 7000)
const timer = setTimeout(() => resolve("timeout"), 500)
void TuiPluginRuntime.dispose().then(() => {
clearTimeout(timer)
resolve("done")
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import { Option } from "effect"
import { RouteSchema } from "@/cli/cmd/tui/context/route"
import { tryJsonConfig } from "@/util/json"
const parse = (raw: string | undefined) => tryJsonConfig(raw, RouteSchema, "OPENCODE_ROUTE")
describe("tryJsonConfig with RouteSchema", () => {
test("returns None for undefined input", () => {
expect(Option.isNone(parse(undefined))).toBe(true)
})
test("returns None for empty string", () => {
expect(Option.isNone(parse(""))).toBe(true)
})
test("returns None for malformed JSON", () => {
expect(Option.isNone(parse("{not json"))).toBe(true)
})
test("returns None when JSON does not match route schema", () => {
expect(Option.isNone(parse(`{"type":"unknown"}`))).toBe(true)
})
test("returns Some for valid home route", () => {
expect(Option.getOrThrow(parse(`{"type":"home"}`))).toEqual({ type: "home" })
})
test("returns Some for valid session route", () => {
expect(Option.getOrThrow(parse(`{"type":"session","sessionID":"abc123"}`))).toEqual({
type: "session",
sessionID: "abc123",
})
})
test("returns Some for valid plugin route", () => {
expect(Option.getOrThrow(parse(`{"type":"plugin","id":"foo","data":{"x":1}}`))).toEqual({
type: "plugin",
id: "foo",
data: { x: 1 },
})
})
})
@@ -41,7 +41,10 @@ test("replace slot mounts plugin content once", async () => {
)
}
await testRender(() => <App />)
expect(mounts).toBe(1)
const app = await testRender(() => <App />)
try {
expect(mounts).toBe(1)
} finally {
app.renderer.destroy()
}
})
@@ -1,39 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
import { questionToolRequestIndex } from "@/cli/cmd/tui/context/sync"
const request = {
id: "question-new",
sessionID: "session-1",
questions: [],
tool: { messageID: "msg-new", callID: "call-new" },
} satisfies QuestionRequest
function part(status: "running" | "completed" | "error", tool = "question", callID = "call-new"): ToolPart {
return {
id: "part-new",
sessionID: "session-1",
messageID: "msg-new",
type: "tool",
callID,
tool,
state:
status === "running"
? { status, input: {}, time: { start: 1 } }
: status === "completed"
? { status, input: {}, output: "", title: "question", metadata: {}, time: { start: 1, end: 2 } }
: { status, input: {}, error: "Tool execution aborted", time: { start: 1, end: 2 } },
}
}
describe("tui sync", () => {
test("matches terminal tool-owned question requests", () => {
const stale = { ...request, id: "question-old", tool: { messageID: "msg-old", callID: "call-old" } }
expect(questionToolRequestIndex([stale, request], part("error"))).toBe(1)
expect(questionToolRequestIndex([stale, request], part("completed"))).toBe(1)
expect(questionToolRequestIndex([stale, request], part("completed", "plan_exit"))).toBe(1)
expect(questionToolRequestIndex([stale, request], part("running"))).toBe(-1)
expect(questionToolRequestIndex([stale, request], part("error", "bash", "call-other"))).toBe(-1)
})
})
@@ -1101,9 +1101,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.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json")
} finally {
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test"
import { ConfigPermission } from "@/config/permission"
import { InvalidConfigError, requireJsonConfig } from "@/util/json"
const decode = (raw: string) => requireJsonConfig(raw, ConfigPermission.Info, "OPENCODE_PERMISSION")
describe("requireJsonConfig with ConfigPermission.Info", () => {
test("throws InvalidConfigError on malformed JSON", () => {
let caught: unknown
try {
decode("{not json")
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(InvalidConfigError)
const data = caught as InvalidConfigError
expect(data.source).toBe("OPENCODE_PERMISSION")
expect(data.value).toBe("{not json")
expect(typeof data.reason).toBe("string")
expect(data.reason.length).toBeGreaterThan(0)
})
test("throws InvalidConfigError on shape mismatch", () => {
let caught: unknown
try {
// `banana` is not a valid Action ("ask" | "allow" | "deny")
decode(`{"bash":"banana"}`)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(InvalidConfigError)
const data = caught as InvalidConfigError
expect(data.source).toBe("OPENCODE_PERMISSION")
expect(data.value).toBe(`{"bash":"banana"}`)
expect(data.reason.length).toBeGreaterThan(0)
})
test("returns decoded value for Action shorthand", () => {
expect(decode(`"ask"`)).toEqual({ "*": "ask" })
})
test("returns decoded value for object of per-target rules", () => {
expect(decode(`{"bash":"ask","edit":"allow"}`)).toEqual({
bash: "ask",
edit: "allow",
})
})
test("returns decoded value for nested Object rule", () => {
expect(decode(`{"bash":{"rm *":"deny","ls":"allow"}}`)).toEqual({
bash: { "rm *": "deny", ls: "allow" },
})
})
})
@@ -173,8 +173,12 @@ const startWorkspaceSyncingWithFlag = (projectID: ProjectID, experimentalWorkspa
Effect.provide(workspaceLayer(experimentalWorkspaces)),
),
)
const waitForWorkspaceSync = (workspaceID: WorkspaceID, state: Record<string, number>, signal?: AbortSignal) =>
runWorkspace(Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal)))
const waitForWorkspaceSync = (
workspaceID: WorkspaceID,
state: Record<string, number>,
signal?: AbortSignal,
timeout?: number,
) => runWorkspace(Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal, timeout)))
function captureGlobalEvents() {
const events: GlobalEvent[] = []
@@ -1639,9 +1643,9 @@ describe("workspace waitForSync", () => {
await withInstance(async () => {
const sessionID = SessionID.descending("ses_wait_timeout")
await expect(waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_timeout"), { [sessionID]: 1 })).rejects.toThrow(
`Timed out waiting for sync fence: {"${sessionID}":1}`,
)
await expect(
waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_timeout"), { [sessionID]: 1 }, undefined, 25),
).rejects.toThrow(`Timed out waiting for sync fence: {"${sessionID}":1}`)
})
}, 7000)
})
@@ -62,6 +62,7 @@ describe("RuntimeFlags", () => {
expect(flags.experimentalEventSystem).toBe(true)
expect(flags.experimentalWorkspaces).toBe(true)
expect(flags.experimentalIconDiscovery).toBe(true)
expect(flags.experimentalNativeLlm).toBe(true)
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", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(
+47 -17
View File
@@ -1,7 +1,8 @@
import { describe, expect } from "bun:test"
import path from "path"
import { realpath } from "fs/promises"
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 { testEffect } from "../lib/effect"
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(
wait(directory, check),
({ deferred }) =>
Effect.gen(function* () {
yield* trigger
return yield* Deferred.await(deferred).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () => Effect.fail(new Error("timed out waiting for file watcher update")),
}),
)
return yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeout))
}),
({ 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`. */
function noUpdate<E>(
directory: string,
@@ -125,22 +152,25 @@ function ready(directory: string) {
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
yield* nextUpdate(
yield* eventuallyUpdate(
directory,
(evt) => evt.file === file && evt.event === "add",
fs.writeFileString(file, "ready"),
(evt) => evt.file === file,
() => fs.writeFileString(file, `ready-${Math.random()}`),
).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid)
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()
yield* nextUpdate(
yield* eventuallyUpdate(
directory,
(evt) => evt.file === head && evt.event !== "unlink",
fs
.writeFileString(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n")
.pipe(Effect.andThen(fs.writeFileString(head, `ref: refs/heads/${branch}\n`))),
(evt) => (evt.file === head || evt.file === realHead) && evt.event !== "unlink",
() => {
const branch = `watch-${Math.random().toString(36).slice(2)}`
return fs
.writeFileString(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n")
.pipe(Effect.andThen(fs.writeFileString(head, `ref: refs/heads/${branch}\n`)))
},
).pipe(Effect.asVoid)
})
}
+10
View File
@@ -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" } } } }),
)
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -66,7 +66,7 @@ describe("plugin.install.concurrent", () => {
test("serializes concurrent server config updates across processes", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const all = mods("mod-server", 12)
const all = mods("mod-server", 6)
const out = await Promise.all(
all.map((mod) =>
@@ -89,7 +89,7 @@ describe("plugin.install.concurrent", () => {
test("serializes concurrent server+tui config updates across processes", async () => {
await using tmp = await tmpdir()
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(
all.map((mod) =>
@@ -118,7 +118,7 @@ describe("plugin.install.concurrent", () => {
await fs.mkdir(path.dirname(cfg), { recursive: true })
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(
next.map((mod) =>
run({
File diff suppressed because it is too large Load Diff
@@ -5,9 +5,11 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Global } from "@opencode-ai/core/global"
import { Config } from "../../src/config/config"
import { ConfigReference } from "../../src/config/reference"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Git } from "../../src/git"
import { Reference } from "../../src/reference/reference"
import { RepositoryCache } from "../../src/reference/repository-cache"
import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -18,8 +20,7 @@ afterEach(async () => {
const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Reference.layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(RepositoryCache.defaultLayer),
Layer.provide(RuntimeFlags.layer(flags)),
)
@@ -81,44 +82,89 @@ const waitForContent = (
})
describe("reference", () => {
it.live("resolves local and git references", () =>
it.live("resolves supported local and git config forms", () =>
Effect.gen(function* () {
const root = path.resolve("opencode-reference-root")
const local = Reference.resolve({
name: "docs",
reference: { path: "../docs" },
reference: ConfigReference.normalizeEntry({ path: "../docs" }),
directory: path.join(root, "packages", "app"),
worktree: root,
})
const repo = Reference.resolve({
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"),
worktree: root,
})
expect(local.kind).toBe("local")
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")
if (repo.kind === "git") {
expect(repo.repository).toBe("Effect-TS/effect")
expect(repo.branch).toBe("main")
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", () =>
Effect.gen(function* () {
const root = path.resolve("opencode-reference-root")
const references = Reference.resolveAll({
directory: root,
worktree: root,
references: {
references: ConfigReference.normalize({
main: { repository: "owner/repo", branch: "main" },
dev: { repository: "github.com/owner/repo", branch: "dev" },
alsoMain: { repository: "https://github.com/owner/repo", branch: "main" },
},
}),
})
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", () =>
provideTmpdirInstance(
(_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", () => {
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()
let stopped = false
try {
@@ -330,7 +330,7 @@ describe("HttpApi Server.listen", () => {
})
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()
try {
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, { 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"))
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 () => {
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()
try {
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 { resetDatabase } from "../fixture/db"
import { TestInstance } from "../fixture/fixture"
import { markPluginDependenciesReady } from "../fixture/plugin"
import { testEffect } from "../lib/effect"
void Log.init({ print: false })
@@ -118,6 +119,7 @@ function requestCallback(input: {
function writeProviderAuthPlugin(dir: string) {
return Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
yield* fs.writeWithDirs(
path.join(dir, ".opencode", "plugin", "provider-oauth-parity.ts"),
@@ -152,6 +154,7 @@ function writeProviderAuthPlugin(dir: string) {
function writeProviderAuthValidationPlugin(dir: string) {
return Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
yield* fs.writeWithDirs(
path.join(dir, ".opencode", "plugin", "provider-oauth-validation.ts"),
@@ -193,6 +196,7 @@ function writeProviderAuthValidationPlugin(dir: string) {
function writeFunctionOptionsPlugin(dir: string) {
return Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
yield* fs.writeWithDirs(
path.join(dir, ".opencode", "plugin", "provider-function-options.ts"),
@@ -224,6 +228,7 @@ function writeFunctionOptionsPlugin(dir: string) {
function writeProviderModelsMutationPlugin(dir: string) {
return Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
yield* fs.writeWithDirs(
path.join(dir, ".opencode", "plugin", "provider-models-mutation.ts"),
@@ -1,5 +1,5 @@
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 { HttpRouter } from "effect/unstable/http"
import { ChildProcessSpawner } from "effect/unstable/process"
@@ -22,7 +22,7 @@ import { TestLLMServer } from "../lib/llm-server"
import path from "path"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { awaitWithTimeout, testEffect } from "../lib/effect"
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
const it = testEffect(
@@ -265,7 +265,7 @@ function withProject<A, E, E2 = never>(
) {
return Effect.gen(function* () {
const directory = yield* tmpdirScoped({
git: options.git ?? true,
git: options.git ?? false,
config: { formatter: false, lsp: false, ...options.config },
})
yield* options.setup?.(directory) ?? Effect.void
@@ -516,7 +516,7 @@ describe("HttpApi SDK", () => {
)
serverPathParity("matches generated SDK instance read routes", (serverPath) =>
withStandardProject(serverPath, ({ sdk, directory }) =>
withProject(serverPath, { git: true, setup: writeStandardFiles }, ({ sdk, directory }) =>
Effect.gen(function* () {
const project = yield* capture(() => sdk.project.current())
const projects = yield* capture(() => sdk.project.list())
@@ -561,6 +561,7 @@ describe("HttpApi SDK", () => {
foundFile: JSON.stringify(findFiles.data).includes("hello.txt"),
foundText: JSON.stringify(findText.data ?? null).includes("sdk-parity"),
listedFile: JSON.stringify(files.data).includes("hello.txt"),
vcs: { hasBranch: typeof record(vcs.data).branch === "string" },
}
}),
),
@@ -671,6 +672,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) =>
withStandardProject(serverPath, ({ sdk }) =>
Effect.gen(function* () {
@@ -823,7 +888,7 @@ describe("HttpApi SDK", () => {
)
serverPathParity("matches generated SDK project git initialization", (serverPath) =>
withProject(serverPath, { git: false }, ({ sdk, directory }) =>
withProject(serverPath, {}, ({ sdk, directory }) =>
Effect.gen(function* () {
const before = yield* capture(() => sdk.project.current())
const init = yield* capture(() => sdk.project.initGit())

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