Compare commits

...

31 Commits

Author SHA1 Message Date
Simon Klee 73a4f5a654 keybind: match by baseCode for non-Latin layouts
Keyboard shortcuts like Ctrl+C fail on non-Latin input layouts
because the terminal reports the layout-specific character name
instead of the Latin one. Fall back to the baseCode field from
the Kitty keyboard protocol to identify the physical key when
names differ. Consolidate inline modifier checks in TUI
components behind the new matchParsedKey helper.

Issue #21163
2026-04-12 19:15:23 +02:00
Aiden Cline 8b9b9ad31e fix: ensure images read by agent dont count against quota (#22168) 2026-04-12 12:02:39 -05:00
Simon Klee 3729fd5706 chore(github): vouch simonklee (#22127) 2026-04-12 11:33:38 +02:00
Aiden Cline 74b14a2d4e chore: refactor log.ts, go back to glob but add sort (#22107) 2026-04-11 23:09:19 -05:00
Aiden Cline cdb951ec2f feat: make gh copilot use msgs api when available (#22106) 2026-04-11 23:06:35 -05:00
Aiden Cline fc01cad2b8 fix: ensure logger cleanup properly orders list before deleting files (#22101) 2026-04-11 22:07:34 -05:00
opencode-agent[bot] c1ddc0ea2d chore: generate 2026-04-12 01:21:17 +00:00
Kit Langton 319b7655b7 refactor(tool): destroy Truncate facade, effectify Tool.define (#22093) 2026-04-11 21:20:12 -04:00
Kit Langton 824c12c01a refactor(file): destroy FileWatcher facade (#22091) 2026-04-11 21:19:12 -04:00
opencode-agent[bot] 17b2900884 chore: generate 2026-04-12 00:58:05 +00:00
Kit Langton 003010bdb6 refactor(question): destroy Question facade (#22092) 2026-04-11 20:57:01 -04:00
Kit Langton 82a4292934 refactor(file): destroy FileTime facade (#22090) 2026-04-11 20:08:55 -04:00
Kit Langton eea4253d67 refactor(session): destroy Instruction facade (#22089) 2026-04-11 20:04:09 -04:00
opencode-agent[bot] 1eacc3c339 chore: generate 2026-04-12 00:03:01 +00:00
Kit Langton 1a509d62a0 refactor(session): destroy SessionRunState facade (#22064) 2026-04-11 20:01:52 -04:00
opencode-agent[bot] 4c4eef46f1 chore: generate 2026-04-11 22:15:53 +00:00
Tommy D. Rossi d62ec7776e feat: allow session permission updates (#22070) 2026-04-11 17:14:30 -05:00
opencode-agent[bot] cb1e5d9e41 chore: generate 2026-04-11 20:56:22 +00:00
Dax ca5f086759 refactor(server): simplify router middleware with next() (#21720) 2026-04-11 16:55:17 -04:00
opencode-agent[bot] 57c40eb7c2 chore: generate 2026-04-11 20:52:52 +00:00
ryan.h.park 63035f977f fix: enable thinking for zhipuai-coding-plan & prevent Korean IME truncation (#22041)
Co-authored-by: claudianus <claudianus@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 15:51:49 -05:00
opencode-agent[bot] 514d2a36bc chore: update nix node_modules hashes 2026-04-11 19:30:50 +00:00
Aiden Cline 0b6fd5f612 chore: bump ai sdk deps (#22005) 2026-04-11 13:45:14 -05:00
Dax Raad 029e7135b7 hide download button 2026-04-11 14:18:58 -04:00
opencode-agent[bot] c43591f8a2 chore: generate 2026-04-11 18:18:40 +00:00
Dax Raad a2c22714cb ignore: exploration 2026-04-11 14:17:22 -04:00
Kit Langton 312f10f797 refactor(account): destroy Account facade (#22068) 2026-04-11 14:16:36 -04:00
opencode-agent[bot] d1f05b0f3a chore: generate 2026-04-11 16:53:40 +00:00
Kit Langton ccb0b320e1 refactor(session): make SystemPrompt a proper Effect Service (#21992) 2026-04-11 12:52:35 -04:00
Kit Langton 5ee7edaf9e refactor(tool): make Tool.Info init effectful (#21989) 2026-04-11 12:33:17 -04:00
opencode-agent[bot] 27190635ea chore: update nix node_modules hashes 2026-04-11 04:45:55 +00:00
99 changed files with 65777 additions and 60037 deletions
+1
View File
@@ -26,6 +26,7 @@ kommander
r44vc0rp r44vc0rp
rekram1-node rekram1-node
-robinmordasiewicz -robinmordasiewicz
simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team -spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr thdxr
-toastythebot -toastythebot
+21 -18
View File
@@ -319,7 +319,7 @@
"@actions/core": "1.11.1", "@actions/core": "1.11.1",
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1", "@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/amazon-bedrock": "4.0.83", "@ai-sdk/amazon-bedrock": "4.0.93",
"@ai-sdk/anthropic": "3.0.67", "@ai-sdk/anthropic": "3.0.67",
"@ai-sdk/azure": "3.0.49", "@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cerebras": "2.0.41",
@@ -331,7 +331,7 @@
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27", "@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26", "@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23", "@ai-sdk/provider-utils": "4.0.23",
@@ -341,7 +341,6 @@
"@aws-sdk/credential-providers": "3.993.0", "@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3", "@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11", "@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0", "@hono/node-ws": "1.3.0",
@@ -357,7 +356,7 @@
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.4.2", "@openrouter/ai-sdk-provider": "2.5.1",
"@opentui/core": "0.1.97", "@opentui/core": "0.1.97",
"@opentui/solid": "0.1.97", "@opentui/solid": "0.1.97",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
@@ -663,7 +662,7 @@
"@types/node": "22.13.9", "@types/node": "22.13.9",
"@types/semver": "7.7.1", "@types/semver": "7.7.1",
"@typescript/native-preview": "7.0.0-dev.20251207.1", "@typescript/native-preview": "7.0.0-dev.20251207.1",
"ai": "6.0.149", "ai": "6.0.158",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"diff": "8.0.2", "diff": "8.0.2",
"dompurify": "3.3.1", "dompurify": "3.3.1",
@@ -708,7 +707,7 @@
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="], "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.83", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DoRpvIWGU/r83UeJAM9L93Lca8Kf/yP5fIhfEOltMPGP/PXrGe0BZaz0maLSRn8djJ6+HzWIsgu5ZI6bZqXEXg=="], "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="], "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
@@ -1162,8 +1161,6 @@
"@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="],
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="], "@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
@@ -1540,7 +1537,7 @@
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"], "@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.4.2", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-uRQZ4da77gru1I7/lNGJhKbqEIY7o/sPsLlbCM97VY9muGDjM/TaJzuwqIviqKTtXLzF0WDj5qBAi6FhxjvlSg=="], "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.5.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-r1fJL1Cb3gQDa2MpWH/sfx1BsEW0uzlRriJM6eihaKqbtKDmZoBisF32VcVaQYassighX7NGCkF68EsrZA43uQ=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -2386,7 +2383,7 @@
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ai": ["ai@6.0.149", "", { "dependencies": { "@ai-sdk/gateway": "3.0.91", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3asRb/m3ZGH7H4+VTuTgj8eQYJZ9IJUmV0ljLslY92mQp6Zj+NVn4SmFj0TBr2Y/wFBWC3xgn++47tSGOXxdbw=="], "ai": ["ai@6.0.158", "", { "dependencies": { "@ai-sdk/gateway": "3.0.95", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ=="],
"ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="], "ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="],
@@ -5006,7 +5003,11 @@
"@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="], "@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
"@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
@@ -5282,10 +5283,6 @@
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], "@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
"@gitlab/gitlab-ai-provider/openai": ["openai@6.33.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw=="],
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@gitlab/opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], "@gitlab/opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
@@ -5554,7 +5551,9 @@
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.91", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-J39Dh6Gyg6HjG3A7OFKnJMp3QyZ3Eex+XDiX8aFBdRwwZm3jGWaMhkCxQPH7yiQ9kRiErZwHXX/Oexx4SyGGGA=="], "ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.95", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="],
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.83", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DoRpvIWGU/r83UeJAM9L93Lca8Kf/yP5fIhfEOltMPGP/PXrGe0BZaz0maLSRn8djJ6+HzWIsgu5ZI6bZqXEXg=="],
"ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.3.3", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-4fVteGkVedc7fGoA9+qJs4tpYwALezMq14m2Sjub3KmyRlksCbK+WJf67NPdGem8+NZrV2tAN42A1NU3+SiV3w=="], "ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.3.3", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-4fVteGkVedc7fGoA9+qJs4tpYwALezMq14m2Sjub3KmyRlksCbK+WJf67NPdGem8+NZrV2tAN42A1NU3+SiV3w=="],
@@ -5770,6 +5769,8 @@
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FFX4P5Fd6lcQJc2OLngZQkbbJHa0IDDZi087Edb8qRZx6h90krtM61ArbMUL8us/7ZUwojCXnyJ/wQ2Eflx2jQ=="], "opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FFX4P5Fd6lcQJc2OLngZQkbbJHa0IDDZi087Edb8qRZx6h90krtM61ArbMUL8us/7ZUwojCXnyJ/wQ2Eflx2jQ=="],
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="], "opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="], "opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
@@ -5952,8 +5953,6 @@
"@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="], "@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
@@ -6450,6 +6449,8 @@
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -6822,6 +6823,8 @@
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-LkmY8hoc1OkJWnTxberdbo2wKlMTmq1NlyOndHSoR1Y=", "x86_64-linux": "sha256-fNRQYkucjXr1D61HJRScJpDa6+oBdyhgTBxCu+PE2kQ=",
"aarch64-linux": "sha256-TOojgsW0rpYiSuDCV/ZmAuewmkYitB6GBmxus3pXpNo=", "aarch64-linux": "sha256-V8J6kn2nSdXrplyqi6aIqNlHcVjSxvye+yC/YFO7PF4=",
"aarch64-darwin": "sha256-Vf+XYCm3YQQ5HBUK7UDXvEKEDeFUMwlGXQsmzrK+a1E=", "aarch64-darwin": "sha256-6cLmUJVUycGALCmslXuloVGBSlFOSHRjsWjx7KOW8rg=",
"x86_64-darwin": "sha256-68OlpJhmf5tpapxYGhcYjhx9716X+BFoIHTGosA3Yg4=" "x86_64-darwin": "sha256-kcOSO3NFIJh79ylLotG41ovWLQfH5kh1WYFghUu+4HE="
} }
} }
+1 -1
View File
@@ -48,7 +48,7 @@
"drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.46", "effect": "4.0.0-beta.46",
"ai": "6.0.149", "ai": "6.0.158",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
@@ -316,7 +316,8 @@
/* Download Hero Section */ /* Download Hero Section */
[data-component="download-hero"] { [data-component="download-hero"] {
display: grid; /* display: grid; */
display: none;
grid-template-columns: 260px 1fr; grid-template-columns: 260px 1fr;
gap: 4rem; gap: 4rem;
padding-bottom: 2rem; padding-bottom: 2rem;
+8 -4
View File
@@ -39,6 +39,11 @@
"bun": "./src/pty/pty.bun.ts", "bun": "./src/pty/pty.bun.ts",
"node": "./src/pty/pty.node.ts", "node": "./src/pty/pty.node.ts",
"default": "./src/pty/pty.bun.ts" "default": "./src/pty/pty.bun.ts"
},
"#hono": {
"bun": "./src/server/adapter.bun.ts",
"node": "./src/server/adapter.node.ts",
"default": "./src/server/adapter.bun.ts"
} }
}, },
"devDependencies": { "devDependencies": {
@@ -78,7 +83,7 @@
"@actions/core": "1.11.1", "@actions/core": "1.11.1",
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1", "@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/amazon-bedrock": "4.0.83", "@ai-sdk/amazon-bedrock": "4.0.93",
"@ai-sdk/anthropic": "3.0.67", "@ai-sdk/anthropic": "3.0.67",
"@ai-sdk/azure": "3.0.49", "@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cerebras": "2.0.41",
@@ -90,7 +95,7 @@
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27", "@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26", "@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23", "@ai-sdk/provider-utils": "4.0.23",
@@ -100,7 +105,6 @@
"@aws-sdk/credential-providers": "3.993.0", "@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3", "@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11", "@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0", "@hono/node-ws": "1.3.0",
@@ -116,7 +120,7 @@
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.4.2", "@openrouter/ai-sdk-provider": "2.5.1",
"@opentui/core": "0.1.97", "@opentui/core": "0.1.97",
"@opentui/solid": "0.1.97", "@opentui/solid": "0.1.97",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
+52 -7
View File
@@ -217,6 +217,7 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
- [x] `SessionSummary``session/summary.ts` - [x] `SessionSummary``session/summary.ts`
- [x] `SessionRevert``session/revert.ts` - [x] `SessionRevert``session/revert.ts`
- [x] `Instruction``session/instruction.ts` - [x] `Instruction``session/instruction.ts`
- [x] `SystemPrompt``session/system.ts`
- [x] `Provider``provider/provider.ts` - [x] `Provider``provider/provider.ts`
- [x] `Storage``storage/storage.ts` - [x] `Storage``storage/storage.ts`
- [x] `ShareNext``share/share-next.ts` - [x] `ShareNext``share/share-next.ts`
@@ -229,24 +230,24 @@ Still open:
## Tool interface → Effect ## Tool interface → Effect
Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `init` and `execute` return `Effect` instead of `Promise`. This lets tool implementations compose natively with the Effect pipeline rather than being wrapped in `Effect.promise()` at the call site. Requires: `Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch. Tool definitions should now stay Effect-native all the way through initialization instead of using Promise-returning init callbacks. Tools can still use lazy init callbacks when they need instance-bound state at init time, but those callbacks should return `Effect`, not `Promise`. Remaining work is:
1. Migrate each tool to return Effects 1. Migrate each tool body to return Effects
2. Update `Tool.define()` factory to work with Effects 2. Keep `Tool.define()` inputs Effect-native
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing 3. Update remaining callers to `yield*` tool initialization instead of `await`ing
### Tool migration details ### Tool migration details
Until the tool interface itself returns `Effect`, use this transitional pattern for migrated tools: With `Tool.Info.init()` now effectful, use this transitional pattern for migrated tools that still need Promise-based boundaries internally:
- `Tool.defineEffect(...)` should `yield*` the services the tool depends on and close over them in the returned tool definition. - `Tool.defineEffect(...)` should `yield*` the services the tool depends on and close over them in the returned tool definition.
- Keep the bridge at the Promise boundary only. Prefer a single `Effect.runPromise(...)` in the temporary `async execute(...)` implementation, and move the inner logic into `Effect.fn(...)` helpers instead of scattering `runPromise` islands through the tool body. - Keep the bridge at the Promise boundary only inside the tool body when required by external APIs. Do not return Promise-based init callbacks from `Tool.define()`.
- If a tool starts requiring new services, wire them into `ToolRegistry.defaultLayer` so production callers resolve the same dependencies as tests. - If a tool starts requiring new services, wire them into `ToolRegistry.defaultLayer` so production callers resolve the same dependencies as tests.
Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`: Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`:
- Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools. - Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools.
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* Effect.promise(() => info.init())`. - Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* info.init()`.
- Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production. - Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production.
This keeps migrated tool tests aligned with the production service graph today, and makes the eventual `Tool.Info``Effect` cleanup mostly mechanical later. This keeps migrated tool tests aligned with the production service graph today, and makes the eventual `Tool.Info``Effect` cleanup mostly mechanical later.
@@ -340,3 +341,47 @@ For each service, the migration is roughly:
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime. - `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration. - `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed. - `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/routes/session.ts` converted; facade removed.
- `Account` — migrated 2026-04-11. Callers in `server/routes/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileTime` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
- `Question` — migrated 2026-04-11. Callers in `server/routes/question.ts` and test converted; facade removed.
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
## Route handler effectification
Route handlers should wrap their entire body in a single `AppRuntime.runPromise(Effect.gen(...))` call, yielding services from context rather than calling facades one-by-one. This eliminates multiple `runPromise` round-trips and lets handlers compose naturally.
```ts
// Before — one facade call per service
;async (c) => {
await SessionRunState.assertNotBusy(id)
await Session.removeMessage({ sessionID: id, messageID })
return c.json(true)
}
// After — one Effect.gen, yield services from context
;async (c) => {
await AppRuntime.runPromise(
Effect.gen(function* () {
const state = yield* SessionRunState.Service
const session = yield* Session.Service
yield* state.assertNotBusy(id)
yield* session.removeMessage({ sessionID: id, messageID })
}),
)
return c.json(true)
}
```
When migrating, always use `{ concurrency: "unbounded" }` with `Effect.all` — route handlers should run independent service calls in parallel, not sequentially.
Route files to convert (each handler that calls facades should be wrapped):
- [ ] `server/routes/session.ts` — heaviest; uses Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, SessionRunState, Agent, Permission, Bus
- [ ] `server/routes/global.ts` — uses Config, Project, Provider, Vcs, Snapshot, Agent
- [ ] `server/routes/provider.ts` — uses Provider, Auth, Config
- [ ] `server/routes/question.ts` — uses Question
- [ ] `server/routes/pty.ts` — uses Pty
- [ ] `server/routes/experimental.ts` — uses Account, ToolRegistry, Agent, MCP, Config
-15
View File
@@ -7,7 +7,6 @@ import {
HttpClientResponse, HttpClientResponse,
} from "effect/unstable/http" } from "effect/unstable/http"
import { makeRuntime } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client" import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo" import { AccountRepo, type AccountRow } from "./repo"
import { normalizeServerUrl } from "./url" import { normalizeServerUrl } from "./url"
@@ -454,18 +453,4 @@ export namespace Account {
) )
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer)) export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
export const { runPromise } = makeRuntime(Service, defaultLayer)
export async function active(): Promise<Info | undefined> {
return Option.getOrUndefined(await runPromise((service) => service.active()))
}
export async function orgsByAccount(): Promise<readonly AccountOrgs[]> {
return runPromise((service) => service.orgsByAccount())
}
export async function switchOrg(accountID: AccountID, orgID: OrgID) {
return runPromise((service) => service.use(accountID, Option.some(orgID)))
}
} }
+5 -7
View File
@@ -398,13 +398,11 @@ export namespace Agent {
}), }),
) )
export const defaultLayer = Layer.suspend(() => export const defaultLayer = layer.pipe(
layer.pipe( Layer.provide(Provider.defaultLayer),
Layer.provide(Provider.defaultLayer), Layer.provide(Auth.defaultLayer),
Layer.provide(Auth.defaultLayer), Layer.provide(Config.defaultLayer),
Layer.provide(Config.defaultLayer), Layer.provide(Skill.defaultLayer),
Layer.provide(Skill.defaultLayer),
),
) )
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
+6 -5
View File
@@ -3,6 +3,7 @@ import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui" import { UI } from "../ui"
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account" import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
import { type AccountError } from "@/account/schema" import { type AccountError } from "@/account/schema"
import { AppRuntime } from "@/effect/app-runtime"
import * as Prompt from "../effect/prompt" import * as Prompt from "../effect/prompt"
import open from "open" import open from "open"
@@ -182,7 +183,7 @@ export const LoginCommand = cmd({
}), }),
async handler(args) { async handler(args) {
UI.empty() UI.empty()
await Account.runPromise((_svc) => loginEffect(args.url)) await AppRuntime.runPromise(loginEffect(args.url))
}, },
}) })
@@ -196,7 +197,7 @@ export const LogoutCommand = cmd({
}), }),
async handler(args) { async handler(args) {
UI.empty() UI.empty()
await Account.runPromise((_svc) => logoutEffect(args.email)) await AppRuntime.runPromise(logoutEffect(args.email))
}, },
}) })
@@ -205,7 +206,7 @@ export const SwitchCommand = cmd({
describe: false, describe: false,
async handler() { async handler() {
UI.empty() UI.empty()
await Account.runPromise((_svc) => switchEffect()) await AppRuntime.runPromise(switchEffect())
}, },
}) })
@@ -214,7 +215,7 @@ export const OrgsCommand = cmd({
describe: false, describe: false,
async handler() { async handler() {
UI.empty() UI.empty()
await Account.runPromise((_svc) => orgsEffect()) await AppRuntime.runPromise(orgsEffect())
}, },
}) })
@@ -223,7 +224,7 @@ export const OpenCommand = cmd({
describe: false, describe: false,
async handler() { async handler() {
UI.empty() UI.empty()
await Account.runPromise((_svc) => openEffect()) await AppRuntime.runPromise(openEffect())
}, },
}) })
+2 -1
View File
@@ -59,6 +59,7 @@ import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
import { TuiConfig } from "@/config/tui" import { TuiConfig } from "@/config/tui"
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin" import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
import { FormatError, FormatUnknownError } from "@/cli/error" import { FormatError, FormatUnknownError } from "@/cli/error"
import { Keybind } from "@/util/keybind"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY // can't set raw mode if not a TTY
@@ -310,7 +311,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
// - Ctrl+C copies and dismisses selection // - Ctrl+C copies and dismisses selection
// - Esc dismisses selection // - Esc dismisses selection
// - Most other key input dismisses selection and is passed through // - Most other key input dismisses selection and is passed through
if (evt.ctrl && evt.name === "c") { if (Keybind.matchParsedKey("ctrl+c", evt)) {
if (!Selection.copy(renderer, toast)) { if (!Selection.copy(renderer, toast)) {
renderer.clearSelection() renderer.clearSelection()
return return
@@ -47,7 +47,7 @@ export function DialogMcp() {
const keybinds = createMemo(() => [ const keybinds = createMemo(() => [
{ {
keybind: Keybind.parse("space")[0], keybind: Keybind.parseOne("space"),
title: "toggle", title: "toggle",
onTrigger: async (option: DialogSelectOption<string>) => { onTrigger: async (option: DialogSelectOption<string>) => {
// Prevent toggling while an operation is already in progress // Prevent toggling while an operation is already in progress
@@ -162,7 +162,7 @@ export function DialogSessionList() {
}, },
}, },
{ {
keybind: Keybind.parse("ctrl+w")[0], keybind: Keybind.parseOne("ctrl+w"),
title: "new workspace", title: "new workspace",
side: "right", side: "right",
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
@@ -5,6 +5,7 @@ import { createSignal } from "solid-js"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { win32FlushInputBuffer } from "../win32" import { win32FlushInputBuffer } from "../win32"
import { getScrollAcceleration } from "../util/scroll" import { getScrollAcceleration } from "../util/scroll"
import { Keybind } from "@/util/keybind"
export function ErrorComponent(props: { export function ErrorComponent(props: {
error: Error error: Error
@@ -25,7 +26,7 @@ export function ErrorComponent(props: {
} }
useKeyboard((evt) => { useKeyboard((evt) => {
if (evt.ctrl && evt.name === "c") { if (Keybind.matchParsedKey("ctrl+c", evt)) {
handleExit() handleExit()
} }
}) })
@@ -589,6 +589,13 @@ export function Prompt(props: PromptProps) {
]) ])
async function submit() { async function submit() {
// IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read
// plainText directly and sync before any downstream reads.
if (input && !input.isDestroyed && input.plainText !== store.prompt.input) {
setStore("prompt", "input", input.plainText)
syncExtmarksWithPromptParts()
}
if (props.disabled) return if (props.disabled) return
if (autocomplete?.visible) return if (autocomplete?.visible) return
if (!store.prompt.input) return if (!store.prompt.input) return
@@ -994,7 +1001,11 @@ export function Prompt(props: PromptProps) {
input.cursorOffset = input.plainText.length input.cursorOffset = input.plainText.length
} }
}} }}
onSubmit={submit} onSubmit={() => {
// IME: double-defer so the last composed character (e.g. Korean
// hangul) is flushed to plainText before we read it for submission.
setTimeout(() => setTimeout(() => submit(), 0), 0)
}}
onPaste={async (event: PasteEvent) => { onPaste={async (event: PasteEvent) => {
if (props.disabled) { if (props.disabled) {
event.preventDefault() event.preventDefault()
+8 -2
View File
@@ -137,12 +137,18 @@ export const TuiThreadCommand = cmd({
), ),
}) })
worker.onerror = (e) => { worker.onerror = (e) => {
Log.Default.error(e) Log.Default.error("thread error", {
message: e.message,
filename: e.filename,
lineno: e.lineno,
colno: e.colno,
error: e.error,
})
} }
const client = Rpc.client<typeof rpc>(worker) const client = Rpc.client<typeof rpc>(worker)
const error = (e: unknown) => { const error = (e: unknown) => {
Log.Default.error(e) Log.Default.error("process error", { error: errorMessage(e) })
} }
const reload = () => { const reload = () => {
client.call("reload", undefined).catch((err) => { client.call("reload", undefined).catch((err) => {
@@ -195,8 +195,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
useKeyboard((evt) => { useKeyboard((evt) => {
setStore("input", "keyboard") setStore("input", "keyboard")
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1) if (evt.name === "up" || Keybind.matchParsedKey("ctrl+p", evt)) move(-1)
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1) if (evt.name === "down" || Keybind.matchParsedKey("ctrl+n", evt)) move(1)
if (evt.name === "pageup") move(-10) if (evt.name === "pageup") move(-10)
if (evt.name === "pagedown") move(10) if (evt.name === "pagedown") move(10)
if (evt.name === "home") moveTo(0) if (evt.name === "home") moveTo(0)
@@ -6,6 +6,7 @@ import { createStore } from "solid-js/store"
import { useToast } from "./toast" import { useToast } from "./toast"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Selection } from "@tui/util/selection" import { Selection } from "@tui/util/selection"
import { Keybind } from "@/util/keybind"
export function Dialog( export function Dialog(
props: ParentProps<{ props: ParentProps<{
@@ -72,12 +73,13 @@ function init() {
}) })
const renderer = useRenderer() const renderer = useRenderer()
useKeyboard((evt) => { useKeyboard((evt) => {
if (store.stack.length === 0) return if (store.stack.length === 0) return
if (evt.defaultPrevented) return if (evt.defaultPrevented) return
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return const isCtrlC = Keybind.matchParsedKey("ctrl+c", evt)
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
if ((evt.name === "escape" || isCtrlC) && renderer.getSelection()?.getSelectedText()) return
if (evt.name === "escape" || isCtrlC) {
if (renderer.getSelection()) { if (renderer.getSelection()) {
renderer.clearSelection() renderer.clearSelection()
} }
-1
View File
@@ -4,7 +4,6 @@ import { pathToFileURL } from "url"
import os from "os" import os from "os"
import { Process } from "../util/process" import { Process } from "../util/process"
import z from "zod" import z from "zod"
import { ModelsDev } from "../provider/models"
import { mergeDeep, pipe, unique } from "remeda" import { mergeDeep, pipe, unique } from "remeda"
import { Global } from "../global" import { Global } from "../global"
import fsNode from "fs/promises" import fsNode from "fs/promises"
@@ -1,9 +1,10 @@
import { Layer, ManagedRuntime } from "effect" import { Layer, ManagedRuntime } from "effect"
import { memoMap } from "./run-service" import { memoMap } from "./run-service"
import { FileWatcher } from "@/file/watcher"
import { Format } from "@/format" import { Format } from "@/format"
import { ShareNext } from "@/share/share-next" import { ShareNext } from "@/share/share-next"
export const BootstrapLayer = Layer.mergeAll(Format.defaultLayer, ShareNext.defaultLayer) export const BootstrapLayer = Layer.mergeAll(Format.defaultLayer, ShareNext.defaultLayer, FileWatcher.defaultLayer)
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap }) export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
-19
View File
@@ -1,6 +1,5 @@
import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect" import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema" import type { SessionID } from "@/session/schema"
@@ -112,22 +111,4 @@ export namespace FileTime {
).pipe(Layer.orDie) ).pipe(Layer.orDie)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export function read(sessionID: SessionID, file: string) {
return runPromise((s) => s.read(sessionID, file))
}
export function get(sessionID: SessionID, file: string) {
return runPromise((s) => s.get(sessionID, file))
}
export async function assert(sessionID: SessionID, filepath: string) {
return runPromise((s) => s.assert(sessionID, filepath))
}
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
return runPromise((s) => s.withLock(filepath, () => Effect.promise(fn)))
}
} }
-7
View File
@@ -8,7 +8,6 @@ import z from "zod"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Git } from "@/git" import { Git } from "@/git"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
@@ -161,10 +160,4 @@ export namespace FileWatcher {
) )
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export function init() {
return runPromise((svc) => svc.init())
}
} }
@@ -5,6 +5,7 @@ import { iife } from "@/util/iife"
import { Log } from "../../util/log" import { Log } from "../../util/log"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { CopilotModels } from "./models" import { CopilotModels } from "./models"
import { MessageV2 } from "@/session/message-v2"
const log = Log.create({ service: "plugin.copilot" }) const log = Log.create({ service: "plugin.copilot" })
@@ -27,11 +28,27 @@ function base(enterpriseUrl?: string) {
return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com" return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com"
} }
function fix(model: Model): Model { // Check if a message is a synthetic user msg used to attach an image from a tool call
function imgMsg(msg: any): boolean {
if (msg?.role !== "user") return false
// Handle the 3 api formats
const content = msg.content
if (typeof content === "string") return content === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT
if (!Array.isArray(content)) return false
return content.some(
(part: any) =>
(part?.type === "text" || part?.type === "input_text") && part.text === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT,
)
}
function fix(model: Model, url: string): Model {
return { return {
...model, ...model,
api: { api: {
...model.api, ...model.api,
url,
npm: "@ai-sdk/github-copilot", npm: "@ai-sdk/github-copilot",
}, },
} }
@@ -44,19 +61,23 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
id: "github-copilot", id: "github-copilot",
async models(provider, ctx) { async models(provider, ctx) {
if (ctx.auth?.type !== "oauth") { if (ctx.auth?.type !== "oauth") {
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model)])) return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model, base())]))
} }
const auth = ctx.auth
return CopilotModels.get( return CopilotModels.get(
base(ctx.auth.enterpriseUrl), base(auth.enterpriseUrl),
{ {
Authorization: `Bearer ${ctx.auth.refresh}`, Authorization: `Bearer ${auth.refresh}`,
"User-Agent": `opencode/${Installation.VERSION}`, "User-Agent": `opencode/${Installation.VERSION}`,
}, },
provider.models, provider.models,
).catch((error) => { ).catch((error) => {
log.error("failed to fetch copilot models", { error }) log.error("failed to fetch copilot models", { error })
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model)])) return Object.fromEntries(
Object.entries(provider.models).map(([id, model]) => [id, fix(model, base(auth.enterpriseUrl))]),
)
}) })
}, },
}, },
@@ -66,10 +87,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
const info = await getAuth() const info = await getAuth()
if (!info || info.type !== "oauth") return {} if (!info || info.type !== "oauth") return {}
const baseURL = base(info.enterpriseUrl)
return { return {
baseURL,
apiKey: "", apiKey: "",
async fetch(request: RequestInfo | URL, init?: RequestInit) { async fetch(request: RequestInfo | URL, init?: RequestInit) {
const info = await getAuth() const info = await getAuth()
@@ -88,7 +106,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
(msg: any) => (msg: any) =>
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"), Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
), ),
isAgent: last?.role !== "user", isAgent: last?.role !== "user" || imgMsg(last),
} }
} }
@@ -100,7 +118,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
(item: any) => (item: any) =>
Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"), Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"),
), ),
isAgent: last?.role !== "user", isAgent: last?.role !== "user" || imgMsg(last),
} }
} }
@@ -122,7 +140,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
part.content.some((nested: any) => nested?.type === "image")), part.content.some((nested: any) => nested?.type === "image")),
), ),
), ),
isAgent: !(last?.role === "user" && hasNonToolCalls), isAgent: !(last?.role === "user" && hasNonToolCalls) || imgMsg(last),
} }
} }
} catch {} } catch {}
@@ -52,13 +52,15 @@ export namespace CopilotModels {
(remote.capabilities.supports.vision ?? false) || (remote.capabilities.supports.vision ?? false) ||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/")) (remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
return { return {
id: key, id: key,
providerID: "github-copilot", providerID: "github-copilot",
api: { api: {
id: remote.id, id: remote.id,
url, url: isMsgApi ? `${url}/v1` : url,
npm: "@ai-sdk/github-copilot", npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot",
}, },
// API response wins // API response wins
status: "active", status: "active",
+10 -8
View File
@@ -124,7 +124,7 @@ export namespace Plugin {
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`, Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
} }
: undefined, : undefined,
fetch: async (...args) => Server.Default().app.fetch(...args), fetch: async (...args) => (await Server.Default()).app.fetch(...args),
}) })
const cfg = yield* config.get() const cfg = yield* config.get()
const input: PluginInput = { const input: PluginInput = {
@@ -210,13 +210,15 @@ export namespace Plugin {
return message return message
}, },
}).pipe( }).pipe(
Effect.catch((message) => Effect.catch(() => {
bus.publish(Session.Event.Error, { // TODO: make proper events for this
error: new NamedError.Unknown({ // bus.publish(Session.Event.Error, {
message: `Failed to load plugin ${load.spec}: ${message}`, // error: new NamedError.Unknown({
}).toObject(), // message: `Failed to load plugin ${load.spec}: ${message}`,
}), // }).toObject(),
), // })
return Effect.void
}),
) )
} }
+2 -2
View File
@@ -2,7 +2,6 @@ import { Plugin } from "../plugin"
import { Format } from "../format" import { Format } from "../format"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { File } from "../file" import { File } from "../file"
import { FileWatcher } from "../file/watcher"
import { Snapshot } from "../snapshot" import { Snapshot } from "../snapshot"
import { Project } from "./project" import { Project } from "./project"
import { Vcs } from "./vcs" import { Vcs } from "./vcs"
@@ -11,6 +10,7 @@ import { Command } from "../command"
import { Instance } from "./instance" import { Instance } from "./instance"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { BootstrapRuntime } from "@/effect/bootstrap-runtime" import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
import { FileWatcher } from "@/file/watcher"
import { ShareNext } from "@/share/share-next" import { ShareNext } from "@/share/share-next"
export async function InstanceBootstrap() { export async function InstanceBootstrap() {
@@ -20,7 +20,7 @@ export async function InstanceBootstrap() {
void BootstrapRuntime.runPromise(Format.Service.use((svc) => svc.init())) void BootstrapRuntime.runPromise(Format.Service.use((svc) => svc.init()))
await LSP.init() await LSP.init()
File.init() File.init()
FileWatcher.init() void BootstrapRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init()))
Vcs.init() Vcs.init()
Snapshot.init() Snapshot.init()
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -774,7 +774,10 @@ export namespace ProviderTransform {
result["chat_template_args"] = { enable_thinking: true } result["chat_template_args"] = { enable_thinking: true }
} }
if (["zai", "zhipuai"].includes(input.model.providerID) && input.model.api.npm === "@ai-sdk/openai-compatible") { if (
["zai", "zhipuai"].some((id) => input.model.providerID.includes(id)) &&
input.model.api.npm === "@ai-sdk/openai-compatible"
) {
result["thinking"] = { result["thinking"] = {
type: "enabled", type: "enabled",
clear_thinking: false, clear_thinking: false,
-23
View File
@@ -2,7 +2,6 @@ import { Deferred, Effect, Layer, Schema, Context } from "effect"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { SessionID, MessageID } from "@/session/schema" import { SessionID, MessageID } from "@/session/schema"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import z from "zod" import z from "zod"
@@ -199,26 +198,4 @@ export namespace Question {
) )
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function ask(input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}): Promise<Answer[]> {
return runPromise((s) => s.ask(input))
}
export async function reply(input: { requestID: QuestionID; answers: Answer[] }) {
return runPromise((s) => s.reply(input))
}
export async function reject(requestID: QuestionID) {
return runPromise((s) => s.reject(requestID))
}
export async function list() {
return runPromise((s) => s.list())
}
} }
@@ -0,0 +1,40 @@
import type { Hono } from "hono"
import { createBunWebSocket } from "hono/bun"
import type { Adapter } from "./adapter"
export const adapter: Adapter = {
create(app: Hono) {
const ws = createBunWebSocket()
return {
upgradeWebSocket: ws.upgradeWebSocket,
async listen(opts) {
const args = {
fetch: app.fetch,
hostname: opts.hostname,
idleTimeout: 0,
websocket: ws.websocket,
} as const
const start = (port: number) => {
try {
return Bun.serve({ ...args, port })
} catch {
return
}
}
const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port)
if (!server) {
throw new Error(`Failed to start server on port ${opts.port}`)
}
if (!server.port) {
throw new Error(`Failed to resolve server address for port ${opts.port}`)
}
return {
port: server.port,
stop(close?: boolean) {
return Promise.resolve(server.stop(close))
},
}
},
}
},
}
@@ -0,0 +1,66 @@
import { createAdaptorServer, type ServerType } from "@hono/node-server"
import { createNodeWebSocket } from "@hono/node-ws"
import type { Hono } from "hono"
import type { Adapter } from "./adapter"
export const adapter: Adapter = {
create(app: Hono) {
const ws = createNodeWebSocket({ app })
return {
upgradeWebSocket: ws.upgradeWebSocket,
async listen(opts) {
const start = (port: number) =>
new Promise<ServerType>((resolve, reject) => {
const server = createAdaptorServer({ fetch: app.fetch })
ws.injectWebSocket(server)
const fail = (err: Error) => {
cleanup()
reject(err)
}
const ready = () => {
cleanup()
resolve(server)
}
const cleanup = () => {
server.off("error", fail)
server.off("listening", ready)
}
server.once("error", fail)
server.once("listening", ready)
server.listen(port, opts.hostname)
})
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
const addr = server.address()
if (!addr || typeof addr === "string") {
throw new Error(`Failed to resolve server address for port ${opts.port}`)
}
let closing: Promise<void> | undefined
return {
port: addr.port,
stop(close?: boolean) {
closing ??= new Promise((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err)
return
}
resolve()
})
if (close) {
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
server.closeAllConnections()
}
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
server.closeIdleConnections()
}
}
})
return closing
},
}
},
}
},
}
+21
View File
@@ -0,0 +1,21 @@
import type { Hono } from "hono"
import type { UpgradeWebSocket } from "hono/ws"
export type Opts = {
port: number
hostname: string
}
export type Listener = {
port: number
stop: (close?: boolean) => Promise<void>
}
export interface Runtime {
upgradeWebSocket: UpgradeWebSocket
listen(opts: Opts): Promise<Listener>
}
export interface Adapter {
create(app: Hono): Runtime
}
@@ -0,0 +1,150 @@
import { Auth } from "@/auth"
import { Log } from "@/util/log"
import { ProviderID } from "@/provider/schema"
import { Hono } from "hono"
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
import z from "zod"
import { errors } from "../error"
import { GlobalRoutes } from "../instance/global"
export function ControlPlaneRoutes(): Hono {
const app = new Hono()
return app
.route("/global", GlobalRoutes())
.put(
"/auth/:providerID",
describeRoute({
summary: "Set auth credentials",
description: "Set authentication credentials",
operationId: "auth.set",
responses: {
200: {
description: "Successfully set authentication credentials",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator(
"param",
z.object({
providerID: ProviderID.zod,
}),
),
validator("json", Auth.Info.zod),
async (c) => {
const providerID = c.req.valid("param").providerID
const info = c.req.valid("json")
await Auth.set(providerID, info)
return c.json(true)
},
)
.delete(
"/auth/:providerID",
describeRoute({
summary: "Remove auth credentials",
description: "Remove authentication credentials",
operationId: "auth.remove",
responses: {
200: {
description: "Successfully removed authentication credentials",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator(
"param",
z.object({
providerID: ProviderID.zod,
}),
),
async (c) => {
const providerID = c.req.valid("param").providerID
await Auth.remove(providerID)
return c.json(true)
},
)
.get(
"/doc",
openAPIRouteHandler(app, {
documentation: {
info: {
title: "opencode",
version: "0.0.3",
description: "opencode api",
},
openapi: "3.1.1",
},
}),
)
.use(
validator(
"query",
z.object({
directory: z.string().optional(),
workspace: z.string().optional(),
}),
),
)
.post(
"/log",
describeRoute({
summary: "Write log",
description: "Write a log entry to the server logs with specified level and metadata.",
operationId: "app.log",
responses: {
200: {
description: "Log entry written successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator(
"json",
z.object({
service: z.string().meta({ description: "Service name for the log entry" }),
level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
message: z.string().meta({ description: "Log message" }),
extra: z
.record(z.string(), z.any())
.optional()
.meta({ description: "Additional metadata for the log entry" }),
}),
),
async (c) => {
const { service, level, message, extra } = c.req.valid("json")
const logger = Log.create({ service })
switch (level) {
case "debug":
logger.debug(message, extra)
break
case "info":
logger.info(message, extra)
break
case "error":
logger.error(message, extra)
break
case "warn":
logger.warn(message, extra)
break
}
return c.json(true)
},
)
}
@@ -11,9 +11,11 @@ import { Session } from "../../session"
import { Config } from "../../config/config" import { Config } from "../../config/config"
import { ConsoleState } from "../../config/console-state" import { ConsoleState } from "../../config/console-state"
import { Account, AccountID, OrgID } from "../../account" import { Account, AccountID, OrgID } from "../../account"
import { AppRuntime } from "../../effect/app-runtime"
import { zodToJsonSchema } from "zod-to-json-schema" import { zodToJsonSchema } from "zod-to-json-schema"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { Effect, Option } from "effect"
import { WorkspaceRoutes } from "./workspace" import { WorkspaceRoutes } from "./workspace"
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
@@ -55,11 +57,20 @@ export const ExperimentalRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
const [consoleState, groups] = await Promise.all([Config.getConsoleState(), Account.orgsByAccount()]) const result = await AppRuntime.runPromise(
return c.json({ Effect.gen(function* () {
...consoleState, const config = yield* Config.Service
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0), const account = yield* Account.Service
}) const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], {
concurrency: "unbounded",
})
return {
...state,
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
}
}),
)
return c.json(result)
}, },
) )
.get( .get(
@@ -80,17 +91,24 @@ export const ExperimentalRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
const [groups, active] = await Promise.all([Account.orgsByAccount(), Account.active()]) const orgs = await AppRuntime.runPromise(
Effect.gen(function* () {
const orgs = groups.flatMap((group) => const account = yield* Account.Service
group.orgs.map((org) => ({ const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], {
accountID: group.account.id, concurrency: "unbounded",
accountEmail: group.account.email, })
accountUrl: group.account.url, const info = Option.getOrUndefined(active)
orgID: org.id, return groups.flatMap((group) =>
orgName: org.name, group.orgs.map((org) => ({
active: !!active && active.id === group.account.id && active.active_org_id === org.id, accountID: group.account.id,
})), accountEmail: group.account.email,
accountUrl: group.account.url,
orgID: org.id,
orgName: org.name,
active: !!info && info.id === group.account.id && info.active_org_id === org.id,
})),
)
}),
) )
return c.json({ orgs }) return c.json({ orgs })
}, },
@@ -115,7 +133,12 @@ export const ExperimentalRoutes = lazy(() =>
validator("json", ConsoleSwitchBody), validator("json", ConsoleSwitchBody),
async (c) => { async (c) => {
const body = c.req.valid("json") const body = c.req.valid("json")
await Account.switchOrg(AccountID.make(body.accountID), OrgID.make(body.orgID)) await AppRuntime.runPromise(
Effect.gen(function* () {
const account = yield* Account.Service
yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID)))
}),
)
return c.json(true) return c.json(true)
}, },
) )
@@ -1,53 +1,33 @@
import { describeRoute, resolver, validator } from "hono-openapi" import { describeRoute, resolver, validator } from "hono-openapi"
import { Hono } from "hono" import { Hono } from "hono"
import { proxy } from "hono/proxy"
import type { UpgradeWebSocket } from "hono/ws" import type { UpgradeWebSocket } from "hono/ws"
import z from "zod" import z from "zod"
import { createHash } from "node:crypto" import { Format } from "../../format"
import * as fs from "node:fs/promises" import { TuiRoutes } from "./tui"
import { Log } from "../util/log" import { Instance } from "../../project/instance"
import { Format } from "../format" import { Vcs } from "../../project/vcs"
import { TuiRoutes } from "./routes/tui" import { Agent } from "../../agent/agent"
import { Instance } from "../project/instance" import { Skill } from "../../skill"
import { Vcs } from "../project/vcs" import { Global } from "../../global"
import { Agent } from "../agent/agent" import { LSP } from "../../lsp"
import { Skill } from "../skill" import { Command } from "../../command"
import { Global } from "../global" import { QuestionRoutes } from "./question"
import { LSP } from "../lsp" import { PermissionRoutes } from "./permission"
import { Command } from "../command" import { ProjectRoutes } from "./project"
import { Flag } from "../flag/flag" import { SessionRoutes } from "./session"
import { QuestionRoutes } from "./routes/question" import { PtyRoutes } from "./pty"
import { PermissionRoutes } from "./routes/permission" import { McpRoutes } from "./mcp"
import { Snapshot } from "@/snapshot" import { FileRoutes } from "./file"
import { ProjectRoutes } from "./routes/project" import { ConfigRoutes } from "./config"
import { SessionRoutes } from "./routes/session" import { ExperimentalRoutes } from "./experimental"
import { PtyRoutes } from "./routes/pty" import { ProviderRoutes } from "./provider"
import { McpRoutes } from "./routes/mcp" import { EventRoutes } from "./event"
import { FileRoutes } from "./routes/file" import { WorkspaceRouterMiddleware } from "./middleware"
import { ConfigRoutes } from "./routes/config"
import { ExperimentalRoutes } from "./routes/experimental"
import { ProviderRoutes } from "./routes/provider"
import { EventRoutes } from "./routes/event"
import { errorHandler } from "./middleware"
import { getMimeType } from "hono/utils/mime"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
const log = Log.create({ service: "server" }) export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
new Hono()
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI .use(WorkspaceRouterMiddleware(upgrade))
? Promise.resolve(null)
: // @ts-expect-error - generated file at build time
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
const DEFAULT_CSP =
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
const csp = (hash = "") =>
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()) =>
app
.onError(errorHandler(log))
.route("/project", ProjectRoutes()) .route("/project", ProjectRoutes())
.route("/pty", PtyRoutes(upgrade)) .route("/pty", PtyRoutes(upgrade))
.route("/config", ConfigRoutes()) .route("/config", ConfigRoutes())
@@ -281,39 +261,3 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()
return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status()))) return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status())))
}, },
) )
.all("/*", async (c) => {
const embeddedWebUI = await embeddedUIPromise
const path = c.req.path
if (embeddedWebUI) {
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
if (!match) return c.json({ error: "Not Found" }, 404)
if (await fs.exists(match)) {
const mime = getMimeType(match) ?? "text/plain"
c.header("Content-Type", mime)
if (mime.startsWith("text/html")) {
c.header("Content-Security-Policy", DEFAULT_CSP)
}
return c.body(new Uint8Array(await fs.readFile(match)))
} else {
return c.json({ error: "Not Found" }, 404)
}
} else {
const response = await proxy(`https://app.opencode.ai${path}`, {
...c.req,
headers: {
...c.req.raw.headers,
host: "app.opencode.ai",
},
})
const match = response.headers.get("content-type")?.includes("text/html")
? (await response.clone().text()).match(
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
)
: undefined
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
response.headers.set("Content-Security-Policy", csp(hash))
return response
}
})
@@ -3,12 +3,10 @@ import type { UpgradeWebSocket } from "hono/ws"
import { getAdaptor } from "@/control-plane/adaptors" import { getAdaptor } from "@/control-plane/adaptors"
import { WorkspaceID } from "@/control-plane/schema" import { WorkspaceID } from "@/control-plane/schema"
import { Workspace } from "@/control-plane/workspace" import { Workspace } from "@/control-plane/workspace"
import { ServerProxy } from "./proxy" import { ServerProxy } from "../proxy"
import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceBootstrap } from "@/project/bootstrap"
import { InstanceRoutes } from "./instance"
import { Session } from "@/session" import { Session } from "@/session"
import { SessionID } from "@/session/schema" import { SessionID } from "@/session/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceContext } from "@/control-plane/workspace-context"
@@ -47,9 +45,7 @@ async function getSessionWorkspace(url: URL) {
} }
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler { export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
const routes = lazy(() => InstanceRoutes(upgrade)) return async (c, next) => {
return async (c) => {
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
const directory = Filesystem.resolve( const directory = Filesystem.resolve(
(() => { (() => {
@@ -72,7 +68,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
directory, directory,
init: InstanceBootstrap, init: InstanceBootstrap,
async fn() { async fn() {
return routes().fetch(c.req.raw, c.env) return next()
}, },
}) })
} }
@@ -87,7 +83,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
// The lets the `DELETE /session/:id` endpoint through and we've // The lets the `DELETE /session/:id` endpoint through and we've
// made sure that it will run without an instance // made sure that it will run without an instance
if (url.pathname.match(/\/session\/[^/]+$/) && c.req.method === "DELETE") { if (url.pathname.match(/\/session\/[^/]+$/) && c.req.method === "DELETE") {
return routes().fetch(c.req.raw, c.env) return next()
} }
return new Response(`Workspace not found: ${workspaceID}`, { return new Response(`Workspace not found: ${workspaceID}`, {
@@ -109,7 +105,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
directory: target.directory, directory: target.directory,
init: InstanceBootstrap, init: InstanceBootstrap,
async fn() { async fn() {
return routes().fetch(c.req.raw, c.env) return next()
}, },
}), }),
}) })
@@ -118,7 +114,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
if (local(c.req.method, url.pathname)) { if (local(c.req.method, url.pathname)) {
// No instance provided because we are serving cached data; there // No instance provided because we are serving cached data; there
// is no instance to work with // is no instance to work with
return routes().fetch(c.req.raw, c.env) return next()
} }
if (c.req.header("upgrade")?.toLowerCase() === "websocket") { if (c.req.header("upgrade")?.toLowerCase() === "websocket") {
@@ -3,6 +3,7 @@ import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi" import { resolver } from "hono-openapi"
import { QuestionID } from "@/question/schema" import { QuestionID } from "@/question/schema"
import { Question } from "../../question" import { Question } from "../../question"
import { AppRuntime } from "@/effect/app-runtime"
import z from "zod" import z from "zod"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
@@ -27,7 +28,7 @@ export const QuestionRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
const questions = await Question.list() const questions = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
return c.json(questions) return c.json(questions)
}, },
) )
@@ -59,10 +60,14 @@ export const QuestionRoutes = lazy(() =>
async (c) => { async (c) => {
const params = c.req.valid("param") const params = c.req.valid("param")
const json = c.req.valid("json") const json = c.req.valid("json")
await Question.reply({ await AppRuntime.runPromise(
requestID: params.requestID, Question.Service.use((svc) =>
answers: json.answers, svc.reply({
}) requestID: params.requestID,
answers: json.answers,
}),
),
)
return c.json(true) return c.json(true)
}, },
) )
@@ -92,7 +97,7 @@ export const QuestionRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const params = c.req.valid("param") const params = c.req.valid("param")
await Question.reject(params.requestID) await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(params.requestID)))
return c.json(true) return c.json(true)
}, },
), ),
@@ -13,6 +13,7 @@ import { SessionShare } from "@/share/session"
import { SessionStatus } from "@/session/status" import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary" import { SessionSummary } from "@/session/summary"
import { Todo } from "../../session/todo" import { Todo } from "../../session/todo"
import { Effect } from "effect"
import { AppRuntime } from "../../effect/app-runtime" import { AppRuntime } from "../../effect/app-runtime"
import { Agent } from "../../agent/agent" import { Agent } from "../../agent/agent"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
@@ -273,6 +274,7 @@ export const SessionRoutes = lazy(() =>
"json", "json",
z.object({ z.object({
title: z.string().optional(), title: z.string().optional(),
permission: Permission.Ruleset.optional(),
time: z time: z
.object({ .object({
archived: z.number().optional(), archived: z.number().optional(),
@@ -283,10 +285,17 @@ export const SessionRoutes = lazy(() =>
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
const updates = c.req.valid("json") const updates = c.req.valid("json")
const current = await Session.get(sessionID)
if (updates.title !== undefined) { if (updates.title !== undefined) {
await Session.setTitle({ sessionID, title: updates.title }) await Session.setTitle({ sessionID, title: updates.title })
} }
if (updates.permission !== undefined) {
await Session.setPermission({
sessionID,
permission: Permission.merge(current.permission ?? [], updates.permission),
})
}
if (updates.time?.archived !== undefined) { if (updates.time?.archived !== undefined) {
await Session.setArchived({ sessionID, time: updates.time.archived }) await Session.setArchived({ sessionID, time: updates.time.archived })
} }
@@ -716,11 +725,17 @@ export const SessionRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const params = c.req.valid("param") const params = c.req.valid("param")
await SessionRunState.assertNotBusy(params.sessionID) await AppRuntime.runPromise(
await Session.removeMessage({ Effect.gen(function* () {
sessionID: params.sessionID, const state = yield* SessionRunState.Service
messageID: params.messageID, const session = yield* Session.Service
}) yield* state.assertNotBusy(params.sessionID)
yield* session.removeMessage({
sessionID: params.sessionID,
messageID: params.messageID,
})
}),
)
return c.json(true) return c.json(true)
}, },
) )
+82 -23
View File
@@ -3,31 +3,90 @@ import { NamedError } from "@opencode-ai/util/error"
import { NotFoundError } from "../storage/db" import { NotFoundError } from "../storage/db"
import { Session } from "../session" import { Session } from "../session"
import type { ContentfulStatusCode } from "hono/utils/http-status" import type { ContentfulStatusCode } from "hono/utils/http-status"
import type { ErrorHandler } from "hono" import type { ErrorHandler, MiddlewareHandler } from "hono"
import { HTTPException } from "hono/http-exception" import { HTTPException } from "hono/http-exception"
import type { Log } from "../util/log" import { Log } from "../util/log"
import { Flag } from "@/flag/flag"
import { basicAuth } from "hono/basic-auth"
import { cors } from "hono/cors"
import { compress } from "hono/compress"
export function errorHandler(log: Log.Logger): ErrorHandler { const log = Log.create({ service: "server" })
return (err, c) => {
log.error("failed", { export const ErrorMiddleware: ErrorHandler = (err, c) => {
error: err, log.error("failed", {
}) error: err,
if (err instanceof NamedError) { })
let status: ContentfulStatusCode if (err instanceof NamedError) {
if (err instanceof NotFoundError) status = 404 let status: ContentfulStatusCode
else if (err instanceof Provider.ModelNotFoundError) status = 400 if (err instanceof NotFoundError) status = 404
else if (err.name === "ProviderAuthValidationFailed") status = 400 else if (err instanceof Provider.ModelNotFoundError) status = 400
else if (err.name.startsWith("Worktree")) status = 400 else if (err.name === "ProviderAuthValidationFailed") status = 400
else status = 500 else if (err.name.startsWith("Worktree")) status = 400
return c.json(err.toObject(), { status }) else status = 500
} return c.json(err.toObject(), { status })
if (err instanceof Session.BusyError) { }
return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 }) if (err instanceof Session.BusyError) {
} return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 })
if (err instanceof HTTPException) return err.getResponse() }
const message = err instanceof Error && err.stack ? err.stack : err.toString() if (err instanceof HTTPException) return err.getResponse()
return c.json(new NamedError.Unknown({ message }).toObject(), { const message = err instanceof Error && err.stack ? err.stack : err.toString()
status: 500, return c.json(new NamedError.Unknown({ message }).toObject(), {
status: 500,
})
}
export const AuthMiddleware: MiddlewareHandler = (c, next) => {
// Allow CORS preflight requests to succeed without auth.
// Browser clients sending Authorization headers will preflight with OPTIONS.
if (c.req.method === "OPTIONS") return next()
const password = Flag.OPENCODE_SERVER_PASSWORD
if (!password) return next()
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
return basicAuth({ username, password })(c, next)
}
export const LoggerMiddleware: MiddlewareHandler = async (c, next) => {
const skip = c.req.path === "/log"
if (!skip) {
log.info("request", {
method: c.req.method,
path: c.req.path,
}) })
} }
const timer = log.time("request", {
method: c.req.method,
path: c.req.path,
})
await next()
if (!skip) timer.stop()
}
export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler {
return cors({
maxAge: 86_400,
origin(input) {
if (!input) return
if (input.startsWith("http://localhost:")) return input
if (input.startsWith("http://127.0.0.1:")) return input
if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost")
return input
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input
if (opts?.cors?.includes(input)) return input
},
})
}
const zipped = compress()
export const CompressionMiddleware: MiddlewareHandler = (c, next) => {
const path = c.req.path
const method = c.req.method
if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return next()
if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return next()
return zipped(c, next)
} }
+28 -275
View File
@@ -1,24 +1,14 @@
import { Log } from "../util/log" import { generateSpecs } from "hono-openapi"
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
import { Hono } from "hono" import { Hono } from "hono"
import { compress } from "hono/compress" import { adapter } from "#hono"
import { createNodeWebSocket } from "@hono/node-ws"
import { cors } from "hono/cors"
import { basicAuth } from "hono/basic-auth"
import type { UpgradeWebSocket } from "hono/ws"
import z from "zod"
import { Auth } from "../auth"
import { Flag } from "../flag/flag"
import { ProviderID } from "../provider/schema"
import { WorkspaceRouterMiddleware } from "./router"
import { errors } from "./error"
import { GlobalRoutes } from "./routes/global"
import { MDNS } from "./mdns" import { MDNS } from "./mdns"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { errorHandler } from "./middleware" import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
import { InstanceRoutes } from "./instance" import { InstanceRoutes } from "./instance"
import { initProjectors } from "./projectors" import { initProjectors } from "./projectors"
import { createAdaptorServer, type ServerType } from "@hono/node-server" import { Log } from "@/util/log"
import { ControlPlaneRoutes } from "./control"
import { UIRoutes } from "./ui"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false globalThis.AI_SDK_LOG_WARNINGS = false
@@ -26,6 +16,8 @@ globalThis.AI_SDK_LOG_WARNINGS = false
initProjectors() initProjectors()
export namespace Server { export namespace Server {
const log = Log.create({ service: "server" })
export type Listener = { export type Listener = {
hostname: string hostname: string
port: number port: number
@@ -33,231 +25,31 @@ export namespace Server {
stop: (close?: boolean) => Promise<void> stop: (close?: boolean) => Promise<void>
} }
const log = Log.create({ service: "server" })
const zipped = compress()
const skipCompress = (path: string, method: string) => {
if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return true
if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return true
return false
}
export const Default = lazy(() => create({})) export const Default = lazy(() => create({}))
export function ControlPlaneRoutes(upgrade: UpgradeWebSocket, app = new Hono(), opts?: { cors?: string[] }): Hono {
return app
.onError(errorHandler(log))
.use((c, next) => {
// Allow CORS preflight requests to succeed without auth.
// Browser clients sending Authorization headers will preflight with OPTIONS.
if (c.req.method === "OPTIONS") return next()
const password = Flag.OPENCODE_SERVER_PASSWORD
if (!password) return next()
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
return basicAuth({ username, password })(c, next)
})
.use(async (c, next) => {
const skip = c.req.path === "/log"
if (!skip) {
log.info("request", {
method: c.req.method,
path: c.req.path,
})
}
const timer = log.time("request", {
method: c.req.method,
path: c.req.path,
})
await next()
if (!skip) timer.stop()
})
.use(
cors({
maxAge: 86_400,
origin(input) {
if (!input) return
if (input.startsWith("http://localhost:")) return input
if (input.startsWith("http://127.0.0.1:")) return input
if (
input === "tauri://localhost" ||
input === "http://tauri.localhost" ||
input === "https://tauri.localhost"
)
return input
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input
if (opts?.cors?.includes(input)) return input
},
}),
)
.use((c, next) => {
if (skipCompress(c.req.path, c.req.method)) return next()
return zipped(c, next)
})
.route("/global", GlobalRoutes())
.put(
"/auth/:providerID",
describeRoute({
summary: "Set auth credentials",
description: "Set authentication credentials",
operationId: "auth.set",
responses: {
200: {
description: "Successfully set authentication credentials",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator(
"param",
z.object({
providerID: ProviderID.zod,
}),
),
validator("json", Auth.Info.zod),
async (c) => {
const providerID = c.req.valid("param").providerID
const info = c.req.valid("json")
await Auth.set(providerID, info)
return c.json(true)
},
)
.delete(
"/auth/:providerID",
describeRoute({
summary: "Remove auth credentials",
description: "Remove authentication credentials",
operationId: "auth.remove",
responses: {
200: {
description: "Successfully removed authentication credentials",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator(
"param",
z.object({
providerID: ProviderID.zod,
}),
),
async (c) => {
const providerID = c.req.valid("param").providerID
await Auth.remove(providerID)
return c.json(true)
},
)
.get(
"/doc",
openAPIRouteHandler(app, {
documentation: {
info: {
title: "opencode",
version: "0.0.3",
description: "opencode api",
},
openapi: "3.1.1",
},
}),
)
.use(
validator(
"query",
z.object({
directory: z.string().optional(),
workspace: z.string().optional(),
}),
),
)
.post(
"/log",
describeRoute({
summary: "Write log",
description: "Write a log entry to the server logs with specified level and metadata.",
operationId: "app.log",
responses: {
200: {
description: "Log entry written successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator(
"json",
z.object({
service: z.string().meta({ description: "Service name for the log entry" }),
level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
message: z.string().meta({ description: "Log message" }),
extra: z
.record(z.string(), z.any())
.optional()
.meta({ description: "Additional metadata for the log entry" }),
}),
),
async (c) => {
const { service, level, message, extra } = c.req.valid("json")
const logger = Log.create({ service })
switch (level) {
case "debug":
logger.debug(message, extra)
break
case "info":
logger.info(message, extra)
break
case "error":
logger.error(message, extra)
break
case "warn":
logger.warn(message, extra)
break
}
return c.json(true)
},
)
.use(WorkspaceRouterMiddleware(upgrade))
}
function create(opts: { cors?: string[] }) { function create(opts: { cors?: string[] }) {
const app = new Hono() const app = new Hono()
const ws = createNodeWebSocket({ app }) const runtime = adapter.create(app)
return { return {
app: ControlPlaneRoutes(ws.upgradeWebSocket, app, opts), app: app
ws, .onError(ErrorMiddleware)
.use(AuthMiddleware)
.use(LoggerMiddleware)
.use(CompressionMiddleware)
.use(CorsMiddleware(opts))
.route("/", ControlPlaneRoutes())
.route("/", InstanceRoutes(runtime.upgradeWebSocket))
.route("/", UIRoutes()),
runtime,
} }
} }
export function createApp(opts: { cors?: string[] }) {
return create(opts).app
}
export async function openapi() { export async function openapi() {
// Build a fresh app with all routes registered directly so // Build a fresh app with all routes registered directly so
// hono-openapi can see describeRoute metadata (`.route()` wraps // hono-openapi can see describeRoute metadata (`.route()` wraps
// handlers when the sub-app has a custom errorHandler, which // handlers when the sub-app has a custom errorHandler, which
// strips the metadata symbol). // strips the metadata symbol).
const { app, ws } = create({}) const { app } = create({})
InstanceRoutes(ws.upgradeWebSocket, app)
const result = await generateSpecs(app, { const result = await generateSpecs(app, {
documentation: { documentation: {
info: { info: {
@@ -281,46 +73,21 @@ export namespace Server {
cors?: string[] cors?: string[]
}): Promise<Listener> { }): Promise<Listener> {
const built = create(opts) const built = create(opts)
const start = (port: number) => const server = await built.runtime.listen(opts)
new Promise<ServerType>((resolve, reject) => {
const server = createAdaptorServer({ fetch: built.app.fetch })
built.ws.injectWebSocket(server)
const fail = (err: Error) => {
cleanup()
reject(err)
}
const ready = () => {
cleanup()
resolve(server)
}
const cleanup = () => {
server.off("error", fail)
server.off("listening", ready)
}
server.once("error", fail)
server.once("listening", ready)
server.listen(port, opts.hostname)
})
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
const addr = server.address()
if (!addr || typeof addr === "string") {
throw new Error(`Failed to resolve server address for port ${opts.port}`)
}
const next = new URL("http://localhost") const next = new URL("http://localhost")
next.hostname = opts.hostname next.hostname = opts.hostname
next.port = String(addr.port) next.port = String(server.port)
url = next url = next
const mdns = const mdns =
opts.mdns && opts.mdns &&
addr.port && server.port &&
opts.hostname !== "127.0.0.1" && opts.hostname !== "127.0.0.1" &&
opts.hostname !== "localhost" && opts.hostname !== "localhost" &&
opts.hostname !== "::1" opts.hostname !== "::1"
if (mdns) { if (mdns) {
MDNS.publish(addr.port, opts.mdnsDomain) MDNS.publish(server.port, opts.mdnsDomain)
} else if (opts.mdns) { } else if (opts.mdns) {
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
} }
@@ -328,27 +95,13 @@ export namespace Server {
let closing: Promise<void> | undefined let closing: Promise<void> | undefined
return { return {
hostname: opts.hostname, hostname: opts.hostname,
port: addr.port, port: server.port,
url: next, url: next,
stop(close?: boolean) { stop(close?: boolean) {
closing ??= new Promise((resolve, reject) => { closing ??= (async () => {
if (mdns) MDNS.unpublish() if (mdns) MDNS.unpublish()
server.close((err) => { await server.stop(close)
if (err) { })()
reject(err)
return
}
resolve()
})
if (close) {
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
server.closeAllConnections()
}
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
server.closeIdleConnections()
}
}
})
return closing return closing
}, },
} }
+55
View File
@@ -0,0 +1,55 @@
import { Flag } from "@/flag/flag"
import { Hono } from "hono"
import { proxy } from "hono/proxy"
import { getMimeType } from "hono/utils/mime"
import { createHash } from "node:crypto"
import fs from "node:fs/promises"
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
? Promise.resolve(null)
: // @ts-expect-error - generated file at build time
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
const DEFAULT_CSP =
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
const csp = (hash = "") =>
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
export const UIRoutes = (): Hono =>
new Hono().all("/*", async (c) => {
const embeddedWebUI = await embeddedUIPromise
const path = c.req.path
if (embeddedWebUI) {
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
if (!match) return c.json({ error: "Not Found" }, 404)
if (await fs.exists(match)) {
const mime = getMimeType(match) ?? "text/plain"
c.header("Content-Type", mime)
if (mime.startsWith("text/html")) {
c.header("Content-Security-Policy", DEFAULT_CSP)
}
return c.body(new Uint8Array(await fs.readFile(match)))
} else {
return c.json({ error: "Not Found" }, 404)
}
} else {
const response = await proxy(`https://app.opencode.ai${path}`, {
...c.req,
headers: {
...c.req.raw.headers,
host: "app.opencode.ai",
},
})
const match = response.headers.get("content-type")?.includes("text/html")
? (await response.clone().text()).match(
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
)
: undefined
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
response.headers.set("Content-Security-Policy", csp(hash))
return response
}
})
+4
View File
@@ -708,6 +708,10 @@ export namespace Session {
runPromise((svc) => svc.setArchived(input)), runPromise((svc) => svc.setArchived(input)),
) )
export const setPermission = fn(z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }), (input) =>
runPromise((svc) => svc.setPermission(input)),
)
export const setRevert = fn( export const setRevert = fn(
z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }), z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }),
(input) => (input) =>
@@ -4,7 +4,6 @@ import { Effect, Layer, Context } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { withTransientReadRetry } from "@/util/effect-http-client" import { withTransientReadRetry } from "@/util/effect-http-client"
@@ -238,21 +237,7 @@ export namespace Instruction {
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
export function clear(messageID: MessageID) {
return runPromise((svc) => svc.clear(messageID))
}
export async function systemPaths() {
return runPromise((svc) => svc.systemPaths())
}
export function loaded(messages: MessageV2.WithParts[]) { export function loaded(messages: MessageV2.WithParts[]) {
return extract(messages) return extract(messages)
} }
export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: MessageID) {
return runPromise((svc) => svc.resolve(messages, filepath, messageID))
}
} }
+3 -1
View File
@@ -25,6 +25,8 @@ interface FetchDecompressionError extends Error {
} }
export namespace MessageV2 { export namespace MessageV2 {
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:"
export function isMedia(mime: string) { export function isMedia(mime: string) {
return mime.startsWith("image/") || mime === "application/pdf" return mime.startsWith("image/") || mime === "application/pdf"
} }
@@ -808,7 +810,7 @@ export namespace MessageV2 {
parts: [ parts: [
{ {
type: "text" as const, type: "text" as const,
text: "Attached image(s) from tool result:", text: SYNTHETIC_ATTACHMENT_PROMPT,
}, },
...media.map((attachment) => ({ ...media.map((attachment) => ({
type: "file" as const, type: "file" as const,
+21 -10
View File
@@ -102,6 +102,8 @@ export namespace SessionPrompt {
const instruction = yield* Instruction.Service const instruction = yield* Instruction.Service
const state = yield* SessionRunState.Service const state = yield* SessionRunState.Service
const revert = yield* SessionRevert.Service const revert = yield* SessionRevert.Service
const sys = yield* SystemPrompt.Service
const llm = yield* LLM.Service
const run = { const run = {
promise: <A, E>(effect: Effect.Effect<A, E>) => promise: <A, E>(effect: Effect.Effect<A, E>) =>
@@ -180,21 +182,24 @@ export namespace SessionPrompt {
const msgs = onlySubtasks const msgs = onlySubtasks
? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }] ? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }]
: yield* MessageV2.toModelMessagesEffect(context, mdl) : yield* MessageV2.toModelMessagesEffect(context, mdl)
const text = yield* Effect.promise(async (signal) => { const text = yield* llm
const result = await LLM.stream({ .stream({
agent: ag, agent: ag,
user: firstInfo, user: firstInfo,
system: [], system: [],
small: true, small: true,
tools: {}, tools: {},
model: mdl, model: mdl,
abort: signal,
sessionID: input.session.id, sessionID: input.session.id,
retries: 2, retries: 2,
messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs], messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs],
}) })
return result.text .pipe(
}) Stream.filter((e): e is Extract<LLM.Event, { type: "text-delta" }> => e.type === "text-delta"),
Stream.map((e) => e.text),
Stream.mkString,
Effect.orDie,
)
const cleaned = text const cleaned = text
.replace(/<think>[\s\S]*?<\/think>\s*/g, "") .replace(/<think>[\s\S]*?<\/think>\s*/g, "")
.split("\n") .split("\n")
@@ -1462,8 +1467,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const [skills, env, instructions, modelMsgs] = yield* Effect.all([ const [skills, env, instructions, modelMsgs] = yield* Effect.all([
Effect.promise(() => SystemPrompt.skills(agent)), sys.skills(agent),
Effect.promise(() => SystemPrompt.environment(model)), Effect.sync(() => sys.environment(model)),
instruction.system().pipe(Effect.orDie), instruction.system().pipe(Effect.orDie),
MessageV2.toModelMessagesEffect(msgs, model), MessageV2.toModelMessagesEffect(msgs, model),
]) ])
@@ -1687,9 +1692,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
Layer.provide(Plugin.defaultLayer), Layer.provide(Plugin.defaultLayer),
Layer.provide(Session.defaultLayer), Layer.provide(Session.defaultLayer),
Layer.provide(SessionRevert.defaultLayer), Layer.provide(SessionRevert.defaultLayer),
Layer.provide(Agent.defaultLayer), Layer.provide(
Layer.provide(Bus.layer), Layer.mergeAll(
Layer.provide(CrossSpawnSpawner.defaultLayer), Agent.defaultLayer,
SystemPrompt.defaultLayer,
LLM.defaultLayer,
Bus.layer,
CrossSpawnSpawner.defaultLayer,
),
),
), ),
) )
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
@@ -1,6 +1,5 @@
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { Runner } from "@/effect/runner" import { Runner } from "@/effect/runner"
import { makeRuntime } from "@/effect/run-service"
import { Effect, Layer, Scope, Context } from "effect" import { Effect, Layer, Scope, Context } from "effect"
import { Session } from "." import { Session } from "."
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
@@ -106,9 +105,4 @@ export namespace SessionRunState {
) )
export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function assertNotBusy(sessionID: SessionID) {
return runPromise((svc) => svc.assertNotBusy(sessionID))
}
} }
+45 -37
View File
@@ -1,4 +1,4 @@
import { Ripgrep } from "../file/ripgrep" import { Context, Effect, Layer } from "effect"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
@@ -33,44 +33,52 @@ export namespace SystemPrompt {
return [PROMPT_DEFAULT] return [PROMPT_DEFAULT]
} }
export async function environment(model: Provider.Model) { export interface Interface {
const project = Instance.project readonly environment: (model: Provider.Model) => string[]
return [ readonly skills: (agent: Agent.Info) => Effect.Effect<string | undefined>
[
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
`Here is some useful information about the environment you are running in:`,
`<env>`,
` Working directory: ${Instance.directory}`,
` Workspace root folder: ${Instance.worktree}`,
` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
` Today's date: ${new Date().toDateString()}`,
`</env>`,
`<directories>`,
` ${
project.vcs === "git" && false
? await Ripgrep.tree({
cwd: Instance.directory,
limit: 50,
})
: ""
}`,
`</directories>`,
].join("\n"),
]
} }
export async function skills(agent: Agent.Info) { export class Service extends Context.Service<Service, Interface>()("@opencode/SystemPrompt") {}
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
const list = await Skill.available(agent) export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const skill = yield* Skill.Service
return [ return Service.of({
"Skills provide specialized instructions and workflows for specific tasks.", environment(model) {
"Use the skill tool to load a skill when a task matches its description.", const project = Instance.project
// the agents seem to ingest the information about skills a bit better if we present a more verbose return [
// version of them here and a less verbose version in tool description, rather than vice versa. [
Skill.fmt(list, { verbose: true }), `You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
].join("\n") `Here is some useful information about the environment you are running in:`,
} `<env>`,
` Working directory: ${Instance.directory}`,
` Workspace root folder: ${Instance.worktree}`,
` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
` Today's date: ${new Date().toDateString()}`,
`</env>`,
].join("\n"),
]
},
skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) {
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
const list = yield* skill.available(agent)
return [
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
// the agents seem to ingest the information about skills a bit better if we present a more verbose
// version of them here and a less verbose version in tool description, rather than vice versa.
Skill.fmt(list, { verbose: true }),
].join("\n")
}),
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer))
} }
+46 -45
View File
@@ -454,52 +454,53 @@ export const BashTool = Tool.define(
} }
}) })
return async () => { return () =>
const shell = Shell.acceptable() Effect.sync(() => {
const name = Shell.name(shell) const shell = Shell.acceptable()
const chain = const name = Shell.name(shell)
name === "powershell" const chain =
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." name === "powershell"
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." ? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
log.info("bash tool using shell", { shell }) : "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
log.info("bash tool using shell", { shell })
return { return {
description: DESCRIPTION.replaceAll("${directory}", Instance.directory) description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
.replaceAll("${os}", process.platform) .replaceAll("${os}", process.platform)
.replaceAll("${shell}", name) .replaceAll("${shell}", name)
.replaceAll("${chaining}", chain) .replaceAll("${chaining}", chain)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES)) .replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
parameters: Parameters, parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) => execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () { Effect.gen(function* () {
const cwd = params.workdir const cwd = params.workdir
? yield* resolvePath(params.workdir, Instance.directory, shell) ? yield* resolvePath(params.workdir, Instance.directory, shell)
: Instance.directory : Instance.directory
if (params.timeout !== undefined && params.timeout < 0) { if (params.timeout !== undefined && params.timeout < 0) {
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
} }
const timeout = params.timeout ?? DEFAULT_TIMEOUT const timeout = params.timeout ?? DEFAULT_TIMEOUT
const ps = PS.has(name) const ps = PS.has(name)
const root = yield* parse(params.command, ps) const root = yield* parse(params.command, ps)
const scan = yield* collect(root, cwd, ps, shell) const scan = yield* collect(root, cwd, ps, shell)
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
yield* ask(ctx, scan) yield* ask(ctx, scan)
return yield* run( return yield* run(
{ {
shell, shell,
name, name,
command: params.command, command: params.command,
cwd, cwd,
env: yield* shellEnv(ctx, cwd), env: yield* shellEnv(ctx, cwd),
timeout, timeout,
description: params.description, description: params.description,
}, },
ctx, ctx,
) )
}), }),
} }
} })
}), }),
) )
+1 -1
View File
@@ -10,7 +10,7 @@ export const MultiEditTool = Tool.define(
"multiedit", "multiedit",
Effect.gen(function* () { Effect.gen(function* () {
const editInfo = yield* EditTool const editInfo = yield* EditTool
const edit = yield* Effect.promise(() => editInfo.init()) const edit = yield* editInfo.init()
return { return {
description: DESCRIPTION, description: DESCRIPTION,
+75 -75
View File
@@ -17,84 +17,84 @@ export const SkillTool = Tool.define(
Effect.gen(function* () { Effect.gen(function* () {
const skill = yield* Skill.Service const skill = yield* Skill.Service
const rg = yield* Ripgrep.Service const rg = yield* Ripgrep.Service
return () =>
Effect.gen(function* () {
const list = yield* skill.available().pipe(Effect.provide(EffectLogger.layer))
return async () => { const description =
const list = await Effect.runPromise(skill.available().pipe(Effect.provide(EffectLogger.layer))) list.length === 0
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
const description = : [
list.length === 0 "Load a specialized skill that provides domain-specific instructions and workflows.",
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
: [
"Load a specialized skill that provides domain-specific instructions and workflows.",
"",
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"",
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"",
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
Skill.fmt(list, { verbose: false }),
].join("\n")
return {
description,
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const info = yield* skill.get(params.name)
if (!info) {
const all = yield* skill.all()
const available = all.map((s) => s.name).join(", ")
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}
yield* ctx.ask({
permission: "skill",
patterns: [params.name],
always: [params.name],
metadata: {},
})
const dir = path.dirname(info.location)
const base = pathToFileURL(dir).href
const limit = 10
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
Stream.filter((file) => !file.includes("SKILL.md")),
Stream.map((file) => path.resolve(dir, file)),
Stream.take(limit),
Stream.runCollect,
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
)
return {
title: `Loaded skill: ${info.name}`,
output: [
`<skill_content name="${info.name}">`,
`# Skill: ${info.name}`,
"", "",
info.content.trim(), "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"", "",
`Base directory for this skill: ${base}`, "The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
"Note: file list is sampled.",
"", "",
"<skill_files>", 'Tool output includes a `<skill_content name="...">` block with the loaded content.',
files, "",
"</skill_files>", "The following skills provide specialized sets of instructions for particular tasks",
"</skill_content>", "Invoke this tool to load a skill when a task matches one of the available skills listed below:",
].join("\n"), "",
metadata: { Skill.fmt(list, { verbose: false }),
name: info.name, ].join("\n")
dir,
}, return {
} description,
}).pipe(Effect.orDie), parameters: Parameters,
} execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
} Effect.gen(function* () {
const info = yield* skill.get(params.name)
if (!info) {
const all = yield* skill.all()
const available = all.map((s) => s.name).join(", ")
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}
yield* ctx.ask({
permission: "skill",
patterns: [params.name],
always: [params.name],
metadata: {},
})
const dir = path.dirname(info.location)
const base = pathToFileURL(dir).href
const limit = 10
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
Stream.filter((file) => !file.includes("SKILL.md")),
Stream.map((file) => path.resolve(dir, file)),
Stream.take(limit),
Stream.runCollect,
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
)
return {
title: `Loaded skill: ${info.name}`,
output: [
`<skill_content name="${info.name}">`,
`# Skill: ${info.name}`,
"",
info.content.trim(),
"",
`Base directory for this skill: ${base}`,
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
"Note: file list is sampled.",
"",
"<skill_files>",
files,
"</skill_files>",
"</skill_content>",
].join("\n"),
metadata: {
name: info.name,
dir,
},
}
}).pipe(Effect.orDie),
}
})
}), }),
) )
+53 -41
View File
@@ -47,9 +47,13 @@ export namespace Tool {
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> { export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string id: string
init: () => Promise<DefWithoutID<Parameters, M>> init: () => Effect.Effect<DefWithoutID<Parameters, M>>
} }
type Init<Parameters extends z.ZodType, M extends Metadata> =
| DefWithoutID<Parameters, M>
| (() => Effect.Effect<DefWithoutID<Parameters, M>>)
export type InferParameters<T> = export type InferParameters<T> =
T extends Info<infer P, any> T extends Info<infer P, any>
? z.infer<P> ? z.infer<P>
@@ -68,58 +72,66 @@ export namespace Tool {
function wrap<Parameters extends z.ZodType, Result extends Metadata>( function wrap<Parameters extends z.ZodType, Result extends Metadata>(
id: string, id: string,
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, init: Init<Parameters, Result>,
truncate: Truncate.Interface,
agents: Agent.Interface,
) { ) {
return async () => { return () =>
const toolInfo = init instanceof Function ? await init() : { ...init } Effect.gen(function* () {
const execute = toolInfo.execute const toolInfo = init instanceof Function ? { ...(yield* init()) } : { ...init }
toolInfo.execute = (args, ctx) => const execute = toolInfo.execute
Effect.gen(function* () { toolInfo.execute = (args, ctx) =>
yield* Effect.try({ Effect.gen(function* () {
try: () => toolInfo.parameters.parse(args), yield* Effect.try({
catch: (error) => { try: () => toolInfo.parameters.parse(args),
if (error instanceof z.ZodError && toolInfo.formatValidationError) { catch: (error) => {
return new Error(toolInfo.formatValidationError(error), { cause: error }) if (error instanceof z.ZodError && toolInfo.formatValidationError) {
} return new Error(toolInfo.formatValidationError(error), { cause: error })
return new Error( }
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`, return new Error(
{ cause: error }, `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
) { cause: error },
}, )
}) },
const result = yield* execute(args, ctx) })
if (result.metadata.truncated !== undefined) { const result = yield* execute(args, ctx)
return result if (result.metadata.truncated !== undefined) {
} return result
const agent = yield* Effect.promise(() => Agent.get(ctx.agent)) }
const truncated = yield* Effect.promise(() => Truncate.output(result.output, {}, agent)) const agent = yield* agents.get(ctx.agent)
return { const truncated = yield* truncate.output(result.output, {}, agent)
...result, return {
output: truncated.content, ...result,
metadata: { output: truncated.content,
...result.metadata, metadata: {
truncated: truncated.truncated, ...result.metadata,
...(truncated.truncated && { outputPath: truncated.outputPath }), truncated: truncated.truncated,
}, ...(truncated.truncated && { outputPath: truncated.outputPath }),
} },
}).pipe(Effect.orDie) }
return toolInfo }).pipe(Effect.orDie)
} return toolInfo
})
} }
export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>( export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
id: ID, id: ID,
init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>, init: Effect.Effect<Init<Parameters, Result>, never, R>,
): Effect.Effect<Info<Parameters, Result>, never, R> & { id: ID } { ): Effect.Effect<Info<Parameters, Result>, never, R | Truncate.Service | Agent.Service> & { id: ID } {
return Object.assign( return Object.assign(
Effect.map(init, (next) => ({ id, init: wrap(id, next) })), Effect.gen(function* () {
const resolved = yield* init
const truncate = yield* Truncate.Service
const agents = yield* Agent.Service
return { id, init: wrap(id, resolved, truncate, agents) }
}),
{ id }, { id },
) )
} }
export function init<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> { export function init<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> {
return Effect.gen(function* () { return Effect.gen(function* () {
const init = yield* Effect.promise(() => info.init()) const init = yield* info.init()
return { return {
...init, ...init,
id: info.id, id: info.id,
-7
View File
@@ -2,7 +2,6 @@ import { NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Schedule, Context } from "effect" import { Cause, Duration, Effect, Layer, Schedule, Context } from "effect"
import path from "path" import path from "path"
import type { Agent } from "../agent/agent" import type { Agent } from "../agent/agent"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { evaluate } from "@/permission/evaluate" import { evaluate } from "@/permission/evaluate"
import { Identifier } from "../id/id" import { Identifier } from "../id/id"
@@ -135,10 +134,4 @@ export namespace Truncate {
) )
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer)) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
return runPromise((s) => s.output(text, options, agent))
}
} }
+70 -2
View File
@@ -6,15 +6,70 @@ export namespace Keybind {
* Keybind info derived from OpenTUI's ParsedKey with our custom `leader` field. * Keybind info derived from OpenTUI's ParsedKey with our custom `leader` field.
* This ensures type compatibility and catches missing fields at compile time. * This ensures type compatibility and catches missing fields at compile time.
*/ */
export type Info = Pick<ParsedKey, "name" | "ctrl" | "meta" | "shift" | "super"> & { export type Info = Pick<ParsedKey, "name" | "ctrl" | "meta" | "shift" | "super" | "baseCode"> & {
leader: boolean // our custom field leader: boolean // our custom field
} }
function getBaseCodeName(baseCode: number | undefined): string | undefined {
if (baseCode === undefined || baseCode < 32 || baseCode === 127) {
return undefined
}
try {
const name = String.fromCodePoint(baseCode)
if (name.length === 1 && name >= "A" && name <= "Z") {
return name.toLowerCase()
}
return name
} catch {
return undefined
}
}
export function match(a: Info | undefined, b: Info): boolean { export function match(a: Info | undefined, b: Info): boolean {
if (!a) return false if (!a) return false
const normalizedA = { ...a, super: a.super ?? false } const normalizedA = { ...a, super: a.super ?? false }
const normalizedB = { ...b, super: b.super ?? false } const normalizedB = { ...b, super: b.super ?? false }
return isDeepEqual(normalizedA, normalizedB) if (isDeepEqual(normalizedA, normalizedB)) {
return true
}
const modifiersA = {
ctrl: normalizedA.ctrl,
meta: normalizedA.meta,
shift: normalizedA.shift,
super: normalizedA.super,
leader: normalizedA.leader,
}
const modifiersB = {
ctrl: normalizedB.ctrl,
meta: normalizedB.meta,
shift: normalizedB.shift,
super: normalizedB.super,
leader: normalizedB.leader,
}
if (!isDeepEqual(modifiersA, modifiersB)) {
return false
}
return (
normalizedA.name === normalizedB.name ||
getBaseCodeName(normalizedA.baseCode) === normalizedB.name ||
getBaseCodeName(normalizedB.baseCode) === normalizedA.name
)
}
export function parseOne(key: string): Info {
const parsed = parse(key)
if (parsed.length !== 1) {
throw new Error(`Expected exactly one keybind, got ${parsed.length}: ${key}`)
}
return parsed[0]!
} }
/** /**
@@ -28,10 +83,23 @@ export namespace Keybind {
meta: key.meta, meta: key.meta,
shift: key.shift, shift: key.shift,
super: key.super ?? false, super: key.super ?? false,
baseCode: key.baseCode,
leader, leader,
} }
} }
export function matchParsedKey(binding: Info | string | undefined, key: ParsedKey, leader = false): boolean {
const bindings = typeof binding === "string" ? parse(binding) : binding ? [binding] : []
if (!bindings.length) {
return false
}
const parsed = fromParsedKey(key, leader)
return bindings.some((item) => match(item, parsed))
}
export function toString(info: Info | undefined): string { export function toString(info: Info | undefined): string {
if (!info) return "" if (!info) return ""
const parts: string[] = [] const parts: string[] = []
+13 -8
View File
@@ -15,6 +15,7 @@ export namespace Log {
WARN: 2, WARN: 2,
ERROR: 3, ERROR: 3,
} }
const keep = 10
let level: Level = "INFO" let level: Level = "INFO"
@@ -78,15 +79,19 @@ export namespace Log {
} }
async function cleanup(dir: string) { async function cleanup(dir: string) {
const files = await Glob.scan("????-??-??T??????.log", { const files = (
cwd: dir, await Glob.scan("????-??-??T??????.log", {
absolute: true, cwd: dir,
include: "file", absolute: false,
}) include: "file",
if (files.length <= 5) return }).catch(() => [])
)
.filter((file) => path.basename(file) === file)
.sort()
if (files.length <= keep) return
const filesToDelete = files.slice(0, -10) const doomed = files.slice(0, -keep)
await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {}))) await Promise.all(doomed.map((file) => fs.unlink(path.join(dir, file)).catch(() => {})))
} }
function formatError(error: Error, depth = 0): string { function formatError(error: Error, depth = 0): string {
+68
View File
@@ -0,0 +1,68 @@
import { Identifier } from "@/id/id"
import { withStatics } from "@/util/schema"
import { DateTime, Effect, Schema } from "effect"
export namespace Message {
export const ID = Schema.String.pipe(Schema.brand("Message.ID")).pipe(
withStatics((s) => ({
create: () => s.make(Identifier.ascending("message")),
prefix: "msg",
})),
)
export class File extends Schema.Class<File>("Message.File")({
url: Schema.String,
mime: Schema.String,
}) {
static create(url: string) {
return new File({
url,
mime: "text/plain",
})
}
}
export class UserContent extends Schema.Class<UserContent>("Message.User.Content")({
text: Schema.String,
synthetic: Schema.Boolean.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
files: Schema.Array(File).pipe(Schema.optional),
}) {}
export class User extends Schema.Class<User>("Message.User")({
id: ID,
type: Schema.Literal("user"),
time: Schema.Struct({
created: Schema.DateTimeUtc,
}),
content: UserContent,
}) {
static create(content: Schema.Schema.Type<typeof UserContent>) {
const msg = new User({
id: ID.create(),
type: "user",
time: {
created: Effect.runSync(DateTime.now),
},
content,
})
return msg
}
static file(url: string) {
return new File({
url,
mime: "text/plain",
})
}
}
export namespace User {}
}
const msg = Message.User.create({
text: "Hello world",
files: [Message.File.create("file://example.com/file.txt")],
})
console.log(JSON.stringify(msg, null, 2))
+52
View File
@@ -79,3 +79,55 @@ await using tmp = await tmpdir({
- Directories are created in the system temp folder with prefix `opencode-test-` - Directories are created in the system temp folder with prefix `opencode-test-`
- Use `await using` for automatic cleanup when the variable goes out of scope - Use `await using` for automatic cleanup when the variable goes out of scope
- Paths are sanitized to strip null bytes (defensive fix for CI environments) - Paths are sanitized to strip null bytes (defensive fix for CI environments)
## Testing With Effects
Use `testEffect(...)` from `test/lib/effect.ts` for tests that exercise Effect services or Effect-based workflows.
### Core Pattern
```typescript
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(MyService.defaultLayer))
describe("my service", () => {
it.live("does the thing", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const svc = yield* MyService.Service
const out = yield* svc.run()
expect(out).toEqual("ok")
}),
),
)
})
```
### `it.effect` vs `it.live`
- Use `it.effect(...)` when the test should run with `TestClock` and `TestConsole`.
- Use `it.live(...)` when the test depends on real time, filesystem mtimes, child processes, git, locks, or other live OS behavior.
- Most integration-style tests in this package use `it.live(...)`.
### Effect Fixtures
Prefer the Effect-aware helpers from `fixture/fixture.ts` instead of building a manual runtime in each test.
- `tmpdirScoped(options?)` creates a scoped temp directory and cleans it up when the Effect scope closes.
- `provideInstance(dir)(effect)` is the low-level helper. It does not create a directory; it just runs an Effect with `Instance.current` bound to `dir`.
- `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup.
- `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server.
Use `provideTmpdirInstance(...)` by default when a test only needs one temp instance. Use `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, or needs to switch instance context within one test.
### Style
- Define `const it = testEffect(...)` near the top of the file.
- Keep the test body inside `Effect.gen(function* () { ... })`.
- Yield services directly with `yield* MyService.Service` or `yield* MyTool`.
- Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` wrappers when `testEffect(...)` already provides the runtime.
- When a test needs instance-local state, prefer `provideTmpdirInstance(...)` or `provideInstance(...)` over manual `Instance.provide(...)` inside Promise-style tests.
+341 -364
View File
@@ -1,445 +1,422 @@
import { describe, test, expect, afterEach } from "bun:test" import { afterEach, describe, expect } from "bun:test"
import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { FileTime } from "../../src/file/time" import { FileTime } from "../../src/file/time"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { SessionID } from "../../src/session/schema" import { SessionID } from "../../src/session/schema"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture" import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => { afterEach(async () => {
await Instance.disposeAll() await Instance.disposeAll()
}) })
async function touch(file: string, time: number) { const it = testEffect(Layer.mergeAll(FileTime.defaultLayer, CrossSpawnSpawner.defaultLayer))
const date = new Date(time)
await fs.utimes(file, date, date)
}
function gate() { const id = SessionID.make("ses_00000000000000000000000001")
let open!: () => void
const wait = new Promise<void>((resolve) => { const put = (file: string, text: string) => Effect.promise(() => fs.writeFile(file, text, "utf-8"))
open = resolve
const touch = (file: string, time: number) =>
Effect.promise(() => {
const date = new Date(time)
return fs.utimes(file, date, date)
}) })
return { open, wait }
} const read = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.read(id, file))
const get = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.get(id, file))
const check = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.assert(id, file))
const lock = <A>(file: string, fn: () => Effect.Effect<A>) => FileTime.Service.use((svc) => svc.withLock(file, fn))
const fail = Effect.fn("FileTimeTest.fail")(function* <A, E, R>(self: Effect.Effect<A, E, R>) {
const exit = yield* self.pipe(Effect.exit)
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause)
return err instanceof Error ? err : new Error(String(err))
}
throw new Error("expected file time effect to fail")
})
describe("file/time", () => { describe("file/time", () => {
const sessionID = SessionID.make("ses_00000000000000000000000001")
describe("read() and get()", () => { describe("read() and get()", () => {
test("stores read timestamp", async () => { it.live("stores read timestamp", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
const filepath = path.join(tmp.path, "file.txt") Effect.gen(function* () {
await fs.writeFile(filepath, "content", "utf-8") const file = path.join(dir, "file.txt")
yield* put(file, "content")
await Instance.provide({ const before = yield* get(id, file)
directory: tmp.path,
fn: async () => {
const before = await FileTime.get(sessionID, filepath)
expect(before).toBeUndefined() expect(before).toBeUndefined()
await FileTime.read(sessionID, filepath) yield* read(id, file)
const after = await FileTime.get(sessionID, filepath) const after = yield* get(id, file)
expect(after).toBeInstanceOf(Date) expect(after).toBeInstanceOf(Date)
expect(after!.getTime()).toBeGreaterThan(0) expect(after!.getTime()).toBeGreaterThan(0)
}, }),
}) ),
}) )
test("tracks separate timestamps per session", async () => { it.live("tracks separate timestamps per session", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
const filepath = path.join(tmp.path, "file.txt") Effect.gen(function* () {
await fs.writeFile(filepath, "content", "utf-8") const file = path.join(dir, "file.txt")
yield* put(file, "content")
await Instance.provide({ const one = SessionID.make("ses_00000000000000000000000002")
directory: tmp.path, const two = SessionID.make("ses_00000000000000000000000003")
fn: async () => { yield* read(one, file)
await FileTime.read(SessionID.make("ses_00000000000000000000000002"), filepath) yield* read(two, file)
await FileTime.read(SessionID.make("ses_00000000000000000000000003"), filepath)
const time1 = await FileTime.get(SessionID.make("ses_00000000000000000000000002"), filepath) const first = yield* get(one, file)
const time2 = await FileTime.get(SessionID.make("ses_00000000000000000000000003"), filepath) const second = yield* get(two, file)
expect(time1).toBeDefined() expect(first).toBeDefined()
expect(time2).toBeDefined() expect(second).toBeDefined()
}, }),
}) ),
}) )
test("updates timestamp on subsequent reads", async () => { it.live("updates timestamp on subsequent reads", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
const filepath = path.join(tmp.path, "file.txt") Effect.gen(function* () {
await fs.writeFile(filepath, "content", "utf-8") const file = path.join(dir, "file.txt")
yield* put(file, "content")
await Instance.provide({ yield* read(id, file)
directory: tmp.path, const first = yield* get(id, file)
fn: async () => {
await FileTime.read(sessionID, filepath)
const first = await FileTime.get(sessionID, filepath)
await FileTime.read(sessionID, filepath) yield* read(id, file)
const second = await FileTime.get(sessionID, filepath) const second = yield* get(id, file)
expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime()) expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime())
}, }),
}) ),
}) )
test("isolates reads by directory", async () => { it.live("isolates reads by directory", () =>
await using one = await tmpdir() Effect.gen(function* () {
await using two = await tmpdir() const one = yield* tmpdirScoped()
await using shared = await tmpdir() const two = yield* tmpdirScoped()
const filepath = path.join(shared.path, "file.txt") const shared = yield* tmpdirScoped()
await fs.writeFile(filepath, "content", "utf-8") const file = path.join(shared, "file.txt")
yield* put(file, "content")
await Instance.provide({ yield* provideInstance(one)(read(id, file))
directory: one.path, const result = yield* provideInstance(two)(get(id, file))
fn: async () => { expect(result).toBeUndefined()
await FileTime.read(sessionID, filepath) }),
}, )
})
await Instance.provide({
directory: two.path,
fn: async () => {
expect(await FileTime.get(sessionID, filepath)).toBeUndefined()
},
})
})
}) })
describe("assert()", () => { describe("assert()", () => {
test("passes when file has not been modified", async () => { it.live("passes when file has not been modified", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
const filepath = path.join(tmp.path, "file.txt") Effect.gen(function* () {
await fs.writeFile(filepath, "content", "utf-8") const file = path.join(dir, "file.txt")
await touch(filepath, 1_000) yield* put(file, "content")
yield* touch(file, 1_000)
await Instance.provide({ yield* read(id, file)
directory: tmp.path, yield* check(id, file)
fn: async () => { }),
await FileTime.read(sessionID, filepath) ),
await FileTime.assert(sessionID, filepath) )
},
})
})
test("throws when file was not read first", async () => { it.live("throws when file was not read first", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
const filepath = path.join(tmp.path, "file.txt") Effect.gen(function* () {
await fs.writeFile(filepath, "content", "utf-8") const file = path.join(dir, "file.txt")
yield* put(file, "content")
await Instance.provide({ const err = yield* fail(check(id, file))
directory: tmp.path, expect(err.message).toContain("You must read file")
fn: async () => { }),
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("You must read file") ),
}, )
})
})
test("throws when file was modified after read", async () => { it.live("throws when file was modified after read", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
const filepath = path.join(tmp.path, "file.txt") Effect.gen(function* () {
await fs.writeFile(filepath, "content", "utf-8") const file = path.join(dir, "file.txt")
await touch(filepath, 1_000) yield* put(file, "content")
yield* touch(file, 1_000)
await Instance.provide({ yield* read(id, file)
directory: tmp.path, yield* put(file, "modified content")
fn: async () => { yield* touch(file, 2_000)
await FileTime.read(sessionID, filepath)
await fs.writeFile(filepath, "modified content", "utf-8")
await touch(filepath, 2_000)
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read")
},
})
})
test("includes timestamps in error message", async () => { const err = yield* fail(check(id, file))
await using tmp = await tmpdir() expect(err.message).toContain("modified since it was last read")
const filepath = path.join(tmp.path, "file.txt") }),
await fs.writeFile(filepath, "content", "utf-8") ),
await touch(filepath, 1_000) )
await Instance.provide({ it.live("includes timestamps in error message", () =>
directory: tmp.path, provideTmpdirInstance((dir) =>
fn: async () => { Effect.gen(function* () {
await FileTime.read(sessionID, filepath) const file = path.join(dir, "file.txt")
await fs.writeFile(filepath, "modified", "utf-8") yield* put(file, "content")
await touch(filepath, 2_000) yield* touch(file, 1_000)
let error: Error | undefined yield* read(id, file)
try { yield* put(file, "modified")
await FileTime.assert(sessionID, filepath) yield* touch(file, 2_000)
} catch (e) {
error = e as Error const err = yield* fail(check(id, file))
} expect(err.message).toContain("Last modification:")
expect(error).toBeDefined() expect(err.message).toContain("Last read:")
expect(error!.message).toContain("Last modification:") }),
expect(error!.message).toContain("Last read:") ),
}, )
})
})
}) })
describe("withLock()", () => { describe("withLock()", () => {
test("executes function within lock", async () => { it.live("executes function within lock", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
const filepath = path.join(tmp.path, "file.txt") Effect.gen(function* () {
const file = path.join(dir, "file.txt")
let hit = false
await Instance.provide({ yield* lock(file, () =>
directory: tmp.path, Effect.sync(() => {
fn: async () => { hit = true
let executed = false return "result"
await FileTime.withLock(filepath, async () => {
executed = true
return "result"
})
expect(executed).toBe(true)
},
})
})
test("returns function result", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await FileTime.withLock(filepath, async () => {
return "success"
})
expect(result).toBe("success")
},
})
})
test("serializes concurrent operations on same file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const order: number[] = []
const hold = gate()
const ready = gate()
const op1 = FileTime.withLock(filepath, async () => {
order.push(1)
ready.open()
await hold.wait
order.push(2)
})
await ready.wait
const op2 = FileTime.withLock(filepath, async () => {
order.push(3)
order.push(4)
})
hold.open()
await Promise.all([op1, op2])
expect(order).toEqual([1, 2, 3, 4])
},
})
})
test("allows concurrent operations on different files", async () => {
await using tmp = await tmpdir()
const filepath1 = path.join(tmp.path, "file1.txt")
const filepath2 = path.join(tmp.path, "file2.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
let started1 = false
let started2 = false
const hold = gate()
const ready = gate()
const op1 = FileTime.withLock(filepath1, async () => {
started1 = true
ready.open()
await hold.wait
expect(started2).toBe(true)
})
await ready.wait
const op2 = FileTime.withLock(filepath2, async () => {
started2 = true
hold.open()
})
await Promise.all([op1, op2])
expect(started1).toBe(true)
expect(started2).toBe(true)
},
})
})
test("releases lock even if function throws", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(
FileTime.withLock(filepath, async () => {
throw new Error("Test error")
}), }),
).rejects.toThrow("Test error") )
let executed = false expect(hit).toBe(true)
await FileTime.withLock(filepath, async () => { }),
executed = true ),
}) )
expect(executed).toBe(true)
}, it.live("returns function result", () =>
}) provideTmpdirInstance((dir) =>
}) Effect.gen(function* () {
const file = path.join(dir, "file.txt")
const result = yield* lock(file, () => Effect.succeed("success"))
expect(result).toBe("success")
}),
),
)
it.live("serializes concurrent operations on same file", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
const order: number[] = []
const hold = yield* Deferred.make<void>()
const ready = yield* Deferred.make<void>()
const one = yield* lock(file, () =>
Effect.gen(function* () {
order.push(1)
yield* Deferred.succeed(ready, void 0)
yield* Deferred.await(hold)
order.push(2)
}),
).pipe(Effect.forkScoped)
yield* Deferred.await(ready)
const two = yield* lock(file, () =>
Effect.sync(() => {
order.push(3)
order.push(4)
}),
).pipe(Effect.forkScoped)
yield* Deferred.succeed(hold, void 0)
yield* Fiber.join(one)
yield* Fiber.join(two)
expect(order).toEqual([1, 2, 3, 4])
}),
),
)
it.live("allows concurrent operations on different files", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const onefile = path.join(dir, "file1.txt")
const twofile = path.join(dir, "file2.txt")
let one = false
let two = false
const hold = yield* Deferred.make<void>()
const ready = yield* Deferred.make<void>()
const a = yield* lock(onefile, () =>
Effect.gen(function* () {
one = true
yield* Deferred.succeed(ready, void 0)
yield* Deferred.await(hold)
expect(two).toBe(true)
}),
).pipe(Effect.forkScoped)
yield* Deferred.await(ready)
const b = yield* lock(twofile, () =>
Effect.sync(() => {
two = true
}),
).pipe(Effect.forkScoped)
yield* Fiber.join(b)
yield* Deferred.succeed(hold, void 0)
yield* Fiber.join(a)
expect(one).toBe(true)
expect(two).toBe(true)
}),
),
)
it.live("releases lock even if function throws", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
const err = yield* fail(lock(file, () => Effect.die(new Error("Test error"))))
expect(err.message).toContain("Test error")
let hit = false
yield* lock(file, () =>
Effect.sync(() => {
hit = true
}),
)
expect(hit).toBe(true)
}),
),
)
}) })
describe("path normalization", () => { describe("path normalization", () => {
test("read with forward slashes, assert with backslashes", async () => { it.live("read with forward slashes, assert with backslashes", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
const filepath = path.join(tmp.path, "file.txt") Effect.gen(function* () {
await fs.writeFile(filepath, "content", "utf-8") const file = path.join(dir, "file.txt")
await touch(filepath, 1_000) yield* put(file, "content")
yield* touch(file, 1_000)
const forwardSlash = filepath.replaceAll("\\", "/") const forward = file.replaceAll("\\", "/")
yield* read(id, forward)
yield* check(id, file)
}),
),
)
await Instance.provide({ it.live("read with backslashes, assert with forward slashes", () =>
directory: tmp.path, provideTmpdirInstance((dir) =>
fn: async () => { Effect.gen(function* () {
await FileTime.read(sessionID, forwardSlash) const file = path.join(dir, "file.txt")
// assert with the native backslash path should still work yield* put(file, "content")
await FileTime.assert(sessionID, filepath) yield* touch(file, 1_000)
},
})
})
test("read with backslashes, assert with forward slashes", async () => { const forward = file.replaceAll("\\", "/")
await using tmp = await tmpdir() yield* read(id, file)
const filepath = path.join(tmp.path, "file.txt") yield* check(id, forward)
await fs.writeFile(filepath, "content", "utf-8") }),
await touch(filepath, 1_000) ),
)
const forwardSlash = filepath.replaceAll("\\", "/") it.live("get returns timestamp regardless of slash direction", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
await Instance.provide({ const forward = file.replaceAll("\\", "/")
directory: tmp.path, yield* read(id, forward)
fn: async () => {
await FileTime.read(sessionID, filepath)
// assert with forward slashes should still work
await FileTime.assert(sessionID, forwardSlash)
},
})
})
test("get returns timestamp regardless of slash direction", async () => { const result = yield* get(id, file)
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
const forwardSlash = filepath.replaceAll("\\", "/")
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, forwardSlash)
const result = await FileTime.get(sessionID, filepath)
expect(result).toBeInstanceOf(Date) expect(result).toBeInstanceOf(Date)
}, }),
}) ),
}) )
test("withLock serializes regardless of slash direction", async () => { it.live("withLock serializes regardless of slash direction", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
const filepath = path.join(tmp.path, "file.txt") Effect.gen(function* () {
const file = path.join(dir, "file.txt")
const forwardSlash = filepath.replaceAll("\\", "/") const forward = file.replaceAll("\\", "/")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const order: number[] = [] const order: number[] = []
const hold = gate() const hold = yield* Deferred.make<void>()
const ready = gate() const ready = yield* Deferred.make<void>()
const op1 = FileTime.withLock(filepath, async () => { const one = yield* lock(file, () =>
order.push(1) Effect.gen(function* () {
ready.open() order.push(1)
await hold.wait yield* Deferred.succeed(ready, void 0)
order.push(2) yield* Deferred.await(hold)
}) order.push(2)
}),
).pipe(Effect.forkScoped)
await ready.wait yield* Deferred.await(ready)
// Use forward-slash variant -- should still serialize against op1 const two = yield* lock(forward, () =>
const op2 = FileTime.withLock(forwardSlash, async () => { Effect.sync(() => {
order.push(3) order.push(3)
order.push(4) order.push(4)
}) }),
).pipe(Effect.forkScoped)
hold.open() yield* Deferred.succeed(hold, void 0)
yield* Fiber.join(one)
yield* Fiber.join(two)
await Promise.all([op1, op2])
expect(order).toEqual([1, 2, 3, 4]) expect(order).toEqual([1, 2, 3, 4])
}, }),
}) ),
}) )
}) })
describe("stat() Filesystem.stat pattern", () => { describe("stat() Filesystem.stat pattern", () => {
test("reads file modification time via Filesystem.stat()", async () => { it.live("reads file modification time via Filesystem.stat()", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
const filepath = path.join(tmp.path, "file.txt") Effect.gen(function* () {
await fs.writeFile(filepath, "content", "utf-8") const file = path.join(dir, "file.txt")
await touch(filepath, 1_000) yield* put(file, "content")
yield* touch(file, 1_000)
await Instance.provide({ yield* read(id, file)
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
const stats = Filesystem.stat(filepath) const stat = Filesystem.stat(file)
expect(stats?.mtime).toBeInstanceOf(Date) expect(stat?.mtime).toBeInstanceOf(Date)
expect(stats!.mtime.getTime()).toBeGreaterThan(0) expect(stat!.mtime.getTime()).toBeGreaterThan(0)
await FileTime.assert(sessionID, filepath) yield* check(id, file)
}, }),
}) ),
}) )
test("detects modification via stat mtime", async () => { it.live("detects modification via stat mtime", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
const filepath = path.join(tmp.path, "file.txt") Effect.gen(function* () {
await fs.writeFile(filepath, "original", "utf-8") const file = path.join(dir, "file.txt")
await touch(filepath, 1_000) yield* put(file, "original")
yield* touch(file, 1_000)
await Instance.provide({ yield* read(id, file)
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
const originalStat = Filesystem.stat(filepath) const first = Filesystem.stat(file)
await fs.writeFile(filepath, "modified", "utf-8") yield* put(file, "modified")
await touch(filepath, 2_000) yield* touch(file, 2_000)
const newStat = Filesystem.stat(filepath) const second = Filesystem.stat(file)
expect(newStat!.mtime.getTime()).toBeGreaterThan(originalStat!.mtime.getTime()) expect(second!.mtime.getTime()).toBeGreaterThan(first!.mtime.getTime())
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow() yield* fail(check(id, file))
}, }),
}) ),
}) )
}) })
}) })
+83
View File
@@ -162,6 +162,24 @@ describe("Keybind.match", () => {
expect(Keybind.match(a, b)).toBe(true) expect(Keybind.match(a, b)).toBe(true)
}) })
test("should match ctrl shortcuts by baseCode from alternate layouts", () => {
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "c" }
const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "ㅊ", baseCode: 99 }
expect(Keybind.match(a, b)).toBe(true)
})
test("should still match the reported character when baseCode is also present", () => {
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "ㅊ" }
const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "ㅊ", baseCode: 99 }
expect(Keybind.match(a, b)).toBe(true)
})
test("should not match a different shortcut just because baseCode exists", () => {
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "ㅊ", baseCode: 99 }
expect(Keybind.match(a, b)).toBe(false)
})
test("should match super+shift combination", () => { test("should match super+shift combination", () => {
const a: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" } const a: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" }
const b: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" } const b: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" }
@@ -419,3 +437,68 @@ describe("Keybind.parse", () => {
]) ])
}) })
}) })
describe("Keybind.parseOne", () => {
test("should parse a single keybind", () => {
expect(Keybind.parseOne("ctrl+x")).toEqual({
ctrl: true,
meta: false,
shift: false,
leader: false,
name: "x",
})
})
test("should reject multiple keybinds", () => {
expect(() => Keybind.parseOne("ctrl+x,ctrl+y")).toThrow("Expected exactly one keybind")
})
})
describe("Keybind.fromParsedKey", () => {
test("should preserve baseCode from ParsedKey", () => {
const result = Keybind.fromParsedKey({
name: "ㅊ",
ctrl: true,
meta: false,
shift: false,
option: false,
number: false,
sequence: "ㅊ",
raw: "\x1b[12618::99;5u",
eventType: "press",
source: "kitty",
baseCode: 99,
})
expect(result).toEqual({
name: "ㅊ",
ctrl: true,
meta: false,
shift: false,
super: false,
leader: false,
baseCode: 99,
})
})
test("should ignore leader unless explicitly requested", () => {
const key = {
name: "ㅊ",
ctrl: true,
meta: false,
shift: false,
option: false,
number: false,
sequence: "ㅊ",
raw: "\x1b[12618::99;5u",
eventType: "press" as const,
source: "kitty" as const,
baseCode: 99,
}
expect(Keybind.matchParsedKey("ctrl+c", key)).toBe(true)
expect(Keybind.matchParsedKey("ctrl+x,ctrl+c", key)).toBe(true)
expect(Keybind.matchParsedKey("ctrl+x,ctrl+y", key)).toBe(false)
expect(Keybind.matchParsedKey("ctrl+c", key, true)).toBe(false)
})
})
@@ -0,0 +1,49 @@
import { abortAfterAny } from "../../src/util/abort"
const MB = 1024 * 1024
const ITERATIONS = 50
const heap = () => {
Bun.gc(true)
return process.memoryUsage().heapUsed / MB
}
const server = Bun.serve({
port: 0,
fetch() {
return new Response("hello from local", {
headers: {
"content-type": "text/plain",
},
})
},
})
const url = `http://127.0.0.1:${server.port}`
async function run() {
const { signal, clearTimeout } = abortAfterAny(30000, new AbortController().signal)
try {
const response = await fetch(url, { signal })
await response.text()
} finally {
clearTimeout()
}
}
try {
await run()
Bun.sleepSync(100)
const baseline = heap()
for (let i = 0; i < ITERATIONS; i++) {
await run()
}
Bun.sleepSync(100)
const after = heap()
process.stdout.write(JSON.stringify({ baseline, after, growth: after - baseline }))
} finally {
server.stop(true)
process.exit(0)
}
@@ -0,0 +1,127 @@
import { describe, test, expect } from "bun:test"
import path from "path"
const projectRoot = path.join(import.meta.dir, "../..")
const worker = path.join(import.meta.dir, "abort-leak-webfetch.ts")
const MB = 1024 * 1024
const ITERATIONS = 50
const getHeapMB = () => {
Bun.gc(true)
return process.memoryUsage().heapUsed / MB
}
describe("memory: abort controller leak", () => {
test("webfetch does not leak memory over many invocations", async () => {
// Measure the abort-timed fetch path in a fresh process so shared tool
// runtime state does not dominate the heap signal.
const proc = Bun.spawn({
cmd: [process.execPath, worker],
cwd: projectRoot,
stdout: "pipe",
stderr: "pipe",
env: process.env,
})
const [code, stdout, stderr] = await Promise.all([
proc.exited,
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
])
if (code !== 0) {
throw new Error(stderr.trim() || stdout.trim() || `worker exited with code ${code}`)
}
const result = JSON.parse(stdout.trim()) as {
baseline: number
after: number
growth: number
}
console.log(`Baseline: ${result.baseline.toFixed(2)} MB`)
console.log(`After ${ITERATIONS} fetches: ${result.after.toFixed(2)} MB`)
console.log(`Growth: ${result.growth.toFixed(2)} MB`)
// Memory growth should be minimal - less than 1MB per 10 requests.
expect(result.growth).toBeLessThan(ITERATIONS / 10)
}, 60000)
test("compare closure vs bind pattern directly", async () => {
const ITERATIONS = 500
// Test OLD pattern: arrow function closure
// Store closures in a map keyed by content to force retention
const closureMap = new Map<string, () => void>()
const timers: Timer[] = []
const controllers: AbortController[] = []
Bun.gc(true)
Bun.sleepSync(100)
const baseline = getHeapMB()
for (let i = 0; i < ITERATIONS; i++) {
// Simulate large response body like webfetch would have
const content = `${i}:${"x".repeat(50 * 1024)}` // 50KB unique per iteration
const controller = new AbortController()
controllers.push(controller)
// OLD pattern - closure captures `content`
const handler = () => {
// Actually use content so it can't be optimized away
if (content.length > 1000000000) controller.abort()
}
closureMap.set(content, handler)
const timeoutId = setTimeout(handler, 30000)
timers.push(timeoutId)
}
Bun.gc(true)
Bun.sleepSync(100)
const after = getHeapMB()
const oldGrowth = after - baseline
console.log(`OLD pattern (closure): ${oldGrowth.toFixed(2)} MB growth (${closureMap.size} closures)`)
// Cleanup after measuring
timers.forEach(clearTimeout)
controllers.forEach((c) => c.abort())
closureMap.clear()
// Test NEW pattern: bind
Bun.gc(true)
Bun.sleepSync(100)
const baseline2 = getHeapMB()
const handlers2: (() => void)[] = []
const timers2: Timer[] = []
const controllers2: AbortController[] = []
for (let i = 0; i < ITERATIONS; i++) {
const _content = `${i}:${"x".repeat(50 * 1024)}` // 50KB - won't be captured
const controller = new AbortController()
controllers2.push(controller)
// NEW pattern - bind doesn't capture surrounding scope
const handler = controller.abort.bind(controller)
handlers2.push(handler)
const timeoutId = setTimeout(handler, 30000)
timers2.push(timeoutId)
}
Bun.gc(true)
Bun.sleepSync(100)
const after2 = getHeapMB()
const newGrowth = after2 - baseline2
// Cleanup after measuring
timers2.forEach(clearTimeout)
controllers2.forEach((c) => c.abort())
handlers2.length = 0
console.log(`NEW pattern (bind): ${newGrowth.toFixed(2)} MB growth`)
console.log(`Improvement: ${(oldGrowth - newGrowth).toFixed(2)} MB saved`)
expect(newGrowth).toBeLessThanOrEqual(oldGrowth)
})
})
@@ -1,5 +1,6 @@
import { afterEach, expect, mock, test } from "bun:test" import { afterEach, expect, mock, test } from "bun:test"
import { CopilotModels } from "@/plugin/github-copilot/models" import { CopilotModels } from "@/plugin/github-copilot/models"
import { CopilotAuthPlugin } from "@/plugin/github-copilot/copilot"
const originalFetch = globalThis.fetch const originalFetch = globalThis.fetch
@@ -115,3 +116,45 @@ test("preserves temperature support from existing provider models", async () =>
expect(models["gpt-4o"].capabilities.temperature).toBe(true) expect(models["gpt-4o"].capabilities.temperature).toBe(true)
expect(models["brand-new"].capabilities.temperature).toBe(true) expect(models["brand-new"].capabilities.temperature).toBe(true)
}) })
test("remaps fallback oauth model urls to the enterprise host", async () => {
globalThis.fetch = mock(() => Promise.reject(new Error("timeout"))) as unknown as typeof fetch
const hooks = await CopilotAuthPlugin({
client: {} as never,
project: {} as never,
directory: "",
worktree: "",
serverUrl: new URL("https://example.com"),
$: {} as never,
})
const models = await hooks.provider!.models!(
{
id: "github-copilot",
models: {
claude: {
id: "claude",
providerID: "github-copilot",
api: {
id: "claude-sonnet-4.5",
url: "https://api.githubcopilot.com/v1",
npm: "@ai-sdk/anthropic",
},
},
},
} as never,
{
auth: {
type: "oauth",
refresh: "token",
access: "token",
expires: Date.now() + 60_000,
enterpriseUrl: "ghe.example.com",
} as never,
},
)
expect(models.claude.api.url).toBe("https://copilot-api.ghe.example.com")
expect(models.claude.api.npm).toBe("@ai-sdk/github-copilot")
})
@@ -13,8 +13,6 @@ const { PluginLoader } = await import("../../src/plugin/loader")
const { readPackageThemes } = await import("../../src/plugin/shared") const { readPackageThemes } = await import("../../src/plugin/shared")
const { Instance } = await import("../../src/project/instance") const { Instance } = await import("../../src/project/instance")
const { Npm } = await import("../../src/npm") const { Npm } = await import("../../src/npm")
const { Bus } = await import("../../src/bus")
const { Session } = await import("../../src/session")
afterAll(() => { afterAll(() => {
if (disableDefault === undefined) { if (disableDefault === undefined) {
@@ -37,27 +35,6 @@ async function load(dir: string) {
}) })
} }
async function errs(dir: string) {
return Instance.provide({
directory: dir,
fn: async () => {
const errors: string[] = []
const off = Bus.subscribe(Session.Event.Error, (evt) => {
const error = evt.properties.error
if (!error || typeof error !== "object") return
if (!("data" in error)) return
if (!error.data || typeof error.data !== "object") return
if (!("message" in error.data)) return
if (typeof error.data.message !== "string") return
errors.push(error.data.message)
})
await Plugin.list()
off()
return errors
},
})
}
describe("plugin.loader.shared", () => { describe("plugin.loader.shared", () => {
test("loads a file:// plugin function export", async () => { test("loads a file:// plugin function export", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
@@ -184,14 +161,13 @@ describe("plugin.loader.shared", () => {
}, },
}) })
const errors = await errs(tmp.path) await load(tmp.path)
const called = await Bun.file(tmp.extra.mark) const called = await Bun.file(tmp.extra.mark)
.text() .text()
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false)
expect(called).toBe(false) expect(called).toBe(false)
expect(errors.some((x) => x.includes("must export id"))).toBe(true)
}) })
test("rejects v1 plugin that exports server and tui together", async () => { test("rejects v1 plugin that exports server and tui together", async () => {
@@ -223,14 +199,13 @@ describe("plugin.loader.shared", () => {
}, },
}) })
const errors = await errs(tmp.path) await load(tmp.path)
const called = await Bun.file(tmp.extra.mark) const called = await Bun.file(tmp.extra.mark)
.text() .text()
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false)
expect(called).toBe(false) expect(called).toBe(false)
expect(errors.some((x) => x.includes("either server() or tui(), not both"))).toBe(true)
}) })
test("resolves npm plugin specs with explicit and default versions", async () => { test("resolves npm plugin specs with explicit and default versions", async () => {
@@ -383,8 +358,7 @@ describe("plugin.loader.shared", () => {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try { try {
const errors = await errs(tmp.path) await load(tmp.path)
expect(errors).toHaveLength(0)
expect(await Bun.file(tmp.extra.mark).text()).toBe("called") expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
} finally { } finally {
install.mockRestore() install.mockRestore()
@@ -436,8 +410,7 @@ describe("plugin.loader.shared", () => {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try { try {
const errors = await errs(tmp.path) await load(tmp.path)
expect(errors).toHaveLength(0)
expect(await Bun.file(tmp.extra.mark).text()).toBe("called") expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
} finally { } finally {
install.mockRestore() install.mockRestore()
@@ -482,14 +455,13 @@ describe("plugin.loader.shared", () => {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try { try {
const errors = await errs(tmp.path) await load(tmp.path)
const called = await Bun.file(tmp.extra.mark) const called = await Bun.file(tmp.extra.mark)
.text() .text()
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false)
expect(called).toBe(false) expect(called).toBe(false)
expect(errors).toHaveLength(0)
} finally { } finally {
install.mockRestore() install.mockRestore()
} }
@@ -546,13 +518,12 @@ describe("plugin.loader.shared", () => {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try { try {
const errors = await errs(tmp.path) await load(tmp.path)
const called = await Bun.file(tmp.extra.mark) const called = await Bun.file(tmp.extra.mark)
.text() .text()
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false)
expect(called).toBe(false) expect(called).toBe(false)
expect(errors.some((x) => x.includes("outside plugin directory"))).toBe(true)
} finally { } finally {
install.mockRestore() install.mockRestore()
} }
@@ -588,30 +559,49 @@ describe("plugin.loader.shared", () => {
} }
}) })
test("publishes session.error when install fails", async () => { test("skips broken plugin when install fails", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["broken-plugin@9.9.9"] }, null, 2)) const ok = path.join(dir, "ok.ts")
const mark = path.join(dir, "ok.txt")
await Bun.write(
ok,
[
"export default {",
' id: "demo.ok",',
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
" return {}",
" },",
"}",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ plugin: ["broken-plugin@9.9.9", pathToFileURL(ok).href] }, null, 2),
)
return { mark }
}, },
}) })
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom")) const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
try { try {
const errors = await errs(tmp.path) await load(tmp.path)
expect(install).toHaveBeenCalledWith("broken-plugin@9.9.9")
expect(errors.some((x) => x.includes("Failed to install plugin broken-plugin@9.9.9") && x.includes("boom"))).toBe( expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
true,
)
} finally { } finally {
install.mockRestore() install.mockRestore()
} }
}) })
test("publishes session.error when plugin init throws", async () => { test("continues loading plugins when plugin init throws", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
const file = pathToFileURL(path.join(dir, "throws.ts")).href const file = pathToFileURL(path.join(dir, "throws.ts")).href
const ok = pathToFileURL(path.join(dir, "ok.ts")).href
const mark = path.join(dir, "ok.txt")
await Bun.write( await Bun.write(
path.join(dir, "throws.ts"), path.join(dir, "throws.ts"),
[ [
@@ -624,51 +614,91 @@ describe("plugin.loader.shared", () => {
"", "",
].join("\n"), ].join("\n"),
) )
await Bun.write(
path.join(dir, "ok.ts"),
[
"export default {",
' id: "demo.ok",',
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
" return {}",
" },",
"}",
"",
].join("\n"),
)
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2)) await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2))
return { file } return { mark }
}, },
}) })
const errors = await errs(tmp.path) await load(tmp.path)
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}: explode`))).toBe(true)
}) })
test("publishes session.error when plugin module has invalid export", async () => { test("continues loading plugins when plugin module has invalid export", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
const file = pathToFileURL(path.join(dir, "invalid.ts")).href const file = pathToFileURL(path.join(dir, "invalid.ts")).href
const ok = pathToFileURL(path.join(dir, "ok.ts")).href
const mark = path.join(dir, "ok.txt")
await Bun.write( await Bun.write(
path.join(dir, "invalid.ts"), path.join(dir, "invalid.ts"),
["export default {", ' id: "demo.invalid",', " nope: true,", "}", ""].join("\n"), ["export default {", ' id: "demo.invalid",', " nope: true,", "}", ""].join("\n"),
) )
await Bun.write(
path.join(dir, "ok.ts"),
[
"export default {",
' id: "demo.ok",',
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
" return {}",
" },",
"}",
"",
].join("\n"),
)
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2)) await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2))
return { file } return { mark }
}, },
}) })
const errors = await errs(tmp.path) await load(tmp.path)
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}`))).toBe(true)
}) })
test("publishes session.error when plugin import fails", async () => { test("continues loading plugins when plugin import fails", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing] }, null, 2)) const ok = pathToFileURL(path.join(dir, "ok.ts")).href
const mark = path.join(dir, "ok.txt")
await Bun.write(
path.join(dir, "ok.ts"),
[
"export default {",
' id: "demo.ok",',
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
" return {}",
" },",
"}",
"",
].join("\n"),
)
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing, ok] }, null, 2))
return { missing } return { mark }
}, },
}) })
const errors = await errs(tmp.path) await load(tmp.path)
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.missing}`))).toBe(true)
}) })
test("loads object plugin via plugin.server", async () => { test("loads object plugin via plugin.server", async () => {
+2 -1
View File
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, test } from "bun:test"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { AppRuntime } from "../../src/effect/app-runtime"
import { FileWatcher } from "../../src/file/watcher" import { FileWatcher } from "../../src/file/watcher"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { GlobalBus } from "../../src/bus/global" import { GlobalBus } from "../../src/bus/global"
@@ -19,7 +20,7 @@ async function withVcs(directory: string, body: () => Promise<void>) {
return Instance.provide({ return Instance.provide({
directory, directory,
fn: async () => { fn: async () => {
FileWatcher.init() void AppRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init()))
Vcs.init() Vcs.init()
await Bun.sleep(500) await Bun.sleep(500)
await body() await body()
@@ -104,6 +104,58 @@ describe("ProviderTransform.options - setCacheKey", () => {
}) })
}) })
describe("ProviderTransform.options - zai/zhipuai thinking", () => {
const sessionID = "test-session-123"
const createModel = (providerID: string) =>
({
id: `${providerID}/glm-4.6`,
providerID,
api: {
id: "glm-4.6",
url: "https://open.bigmodel.cn/api/paas/v4",
npm: "@ai-sdk/openai-compatible",
},
name: "GLM 4.6",
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0.001,
output: 0.002,
cache: { read: 0.0001, write: 0.0002 },
},
limit: {
context: 128000,
output: 8192,
},
status: "active",
options: {},
headers: {},
}) as any
for (const providerID of ["zai-coding-plan", "zai", "zhipuai-coding-plan", "zhipuai"]) {
test(`${providerID} should set thinking cfg`, () => {
const result = ProviderTransform.options({
model: createModel(providerID),
sessionID,
providerOptions: {},
})
expect(result.thinking).toEqual({
type: "enabled",
clear_thinking: false,
})
})
}
})
describe("ProviderTransform.options - google thinkingConfig gating", () => { describe("ProviderTransform.options - google thinkingConfig gating", () => {
const sessionID = "test-session-123" const sessionID = "test-session-123"
@@ -4,6 +4,17 @@ import { Instance } from "../../src/project/instance"
import { QuestionID } from "../../src/question/schema" import { QuestionID } from "../../src/question/schema"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { SessionID } from "../../src/session/schema" import { SessionID } from "../../src/session/schema"
import { AppRuntime } from "../../src/effect/app-runtime"
const ask = (input: { sessionID: SessionID; questions: Question.Info[]; tool?: { messageID: any; callID: string } }) =>
AppRuntime.runPromise(Question.Service.use((svc) => svc.ask(input)))
const list = () => AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
const reply = (input: { requestID: QuestionID; answers: Question.Answer[] }) =>
AppRuntime.runPromise(Question.Service.use((svc) => svc.reply(input)))
const reject = (id: QuestionID) => AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(id)))
afterEach(async () => { afterEach(async () => {
await Instance.disposeAll() await Instance.disposeAll()
@@ -11,9 +22,9 @@ afterEach(async () => {
/** Reject all pending questions so dangling Deferred fibers don't hang the test. */ /** Reject all pending questions so dangling Deferred fibers don't hang the test. */
async function rejectAll() { async function rejectAll() {
const pending = await Question.list() const pending = await list()
for (const req of pending) { for (const req of pending) {
await Question.reject(req.id) await reject(req.id)
} }
} }
@@ -22,7 +33,7 @@ test("ask - returns pending promise", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const promise = Question.ask({ const promise = ask({
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
questions: [ questions: [
{ {
@@ -58,16 +69,16 @@ test("ask - adds to pending list", async () => {
}, },
] ]
const askPromise = Question.ask({ const promise = ask({
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
questions, questions,
}) })
const pending = await Question.list() const pending = await list()
expect(pending.length).toBe(1) expect(pending.length).toBe(1)
expect(pending[0].questions).toEqual(questions) expect(pending[0].questions).toEqual(questions)
await rejectAll() await rejectAll()
await askPromise.catch(() => {}) await promise.catch(() => {})
}, },
}) })
}) })
@@ -90,20 +101,20 @@ test("reply - resolves the pending ask with answers", async () => {
}, },
] ]
const askPromise = Question.ask({ const promise = ask({
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
questions, questions,
}) })
const pending = await Question.list() const pending = await list()
const requestID = pending[0].id const requestID = pending[0].id
await Question.reply({ await reply({
requestID, requestID,
answers: [["Option 1"]], answers: [["Option 1"]],
}) })
const answers = await askPromise const answers = await promise
expect(answers).toEqual([["Option 1"]]) expect(answers).toEqual([["Option 1"]])
}, },
}) })
@@ -114,7 +125,7 @@ test("reply - removes from pending list", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const askPromise = Question.ask({ const promise = ask({
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
questions: [ questions: [
{ {
@@ -128,17 +139,17 @@ test("reply - removes from pending list", async () => {
], ],
}) })
const pending = await Question.list() const pending = await list()
expect(pending.length).toBe(1) expect(pending.length).toBe(1)
await Question.reply({ await reply({
requestID: pending[0].id, requestID: pending[0].id,
answers: [["Option 1"]], answers: [["Option 1"]],
}) })
await askPromise await promise
const pendingAfter = await Question.list() const after = await list()
expect(pendingAfter.length).toBe(0) expect(after.length).toBe(0)
}, },
}) })
}) })
@@ -148,7 +159,7 @@ test("reply - does nothing for unknown requestID", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await Question.reply({ await reply({
requestID: QuestionID.make("que_unknown"), requestID: QuestionID.make("que_unknown"),
answers: [["Option 1"]], answers: [["Option 1"]],
}) })
@@ -164,7 +175,7 @@ test("reject - throws RejectedError", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const askPromise = Question.ask({ const promise = ask({
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
questions: [ questions: [
{ {
@@ -178,10 +189,10 @@ test("reject - throws RejectedError", async () => {
], ],
}) })
const pending = await Question.list() const pending = await list()
await Question.reject(pending[0].id) await reject(pending[0].id)
await expect(askPromise).rejects.toBeInstanceOf(Question.RejectedError) await expect(promise).rejects.toBeInstanceOf(Question.RejectedError)
}, },
}) })
}) })
@@ -191,7 +202,7 @@ test("reject - removes from pending list", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const askPromise = Question.ask({ const promise = ask({
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
questions: [ questions: [
{ {
@@ -205,14 +216,14 @@ test("reject - removes from pending list", async () => {
], ],
}) })
const pending = await Question.list() const pending = await list()
expect(pending.length).toBe(1) expect(pending.length).toBe(1)
await Question.reject(pending[0].id) await reject(pending[0].id)
askPromise.catch(() => {}) // Ignore rejection promise.catch(() => {}) // Ignore rejection
const pendingAfter = await Question.list() const after = await list()
expect(pendingAfter.length).toBe(0) expect(after.length).toBe(0)
}, },
}) })
}) })
@@ -222,7 +233,7 @@ test("reject - does nothing for unknown requestID", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await Question.reject(QuestionID.make("que_unknown")) await reject(QuestionID.make("que_unknown"))
// Should not throw // Should not throw
}, },
}) })
@@ -254,19 +265,19 @@ test("ask - handles multiple questions", async () => {
}, },
] ]
const askPromise = Question.ask({ const promise = ask({
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
questions, questions,
}) })
const pending = await Question.list() const pending = await list()
await Question.reply({ await reply({
requestID: pending[0].id, requestID: pending[0].id,
answers: [["Build"], ["Dev"]], answers: [["Build"], ["Dev"]],
}) })
const answers = await askPromise const answers = await promise
expect(answers).toEqual([["Build"], ["Dev"]]) expect(answers).toEqual([["Build"], ["Dev"]])
}, },
}) })
@@ -279,7 +290,7 @@ test("list - returns all pending requests", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const p1 = Question.ask({ const p1 = ask({
sessionID: SessionID.make("ses_test1"), sessionID: SessionID.make("ses_test1"),
questions: [ questions: [
{ {
@@ -290,7 +301,7 @@ test("list - returns all pending requests", async () => {
], ],
}) })
const p2 = Question.ask({ const p2 = ask({
sessionID: SessionID.make("ses_test2"), sessionID: SessionID.make("ses_test2"),
questions: [ questions: [
{ {
@@ -301,7 +312,7 @@ test("list - returns all pending requests", async () => {
], ],
}) })
const pending = await Question.list() const pending = await list()
expect(pending.length).toBe(2) expect(pending.length).toBe(2)
await rejectAll() await rejectAll()
p1.catch(() => {}) p1.catch(() => {})
@@ -315,7 +326,7 @@ test("list - returns empty when no pending", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const pending = await Question.list() const pending = await list()
expect(pending.length).toBe(0) expect(pending.length).toBe(0)
}, },
}) })
@@ -328,7 +339,7 @@ test("questions stay isolated by directory", async () => {
const p1 = Instance.provide({ const p1 = Instance.provide({
directory: one.path, directory: one.path,
fn: () => fn: () =>
Question.ask({ ask({
sessionID: SessionID.make("ses_one"), sessionID: SessionID.make("ses_one"),
questions: [ questions: [
{ {
@@ -343,7 +354,7 @@ test("questions stay isolated by directory", async () => {
const p2 = Instance.provide({ const p2 = Instance.provide({
directory: two.path, directory: two.path,
fn: () => fn: () =>
Question.ask({ ask({
sessionID: SessionID.make("ses_two"), sessionID: SessionID.make("ses_two"),
questions: [ questions: [
{ {
@@ -357,11 +368,11 @@ test("questions stay isolated by directory", async () => {
const onePending = await Instance.provide({ const onePending = await Instance.provide({
directory: one.path, directory: one.path,
fn: () => Question.list(), fn: () => list(),
}) })
const twoPending = await Instance.provide({ const twoPending = await Instance.provide({
directory: two.path, directory: two.path,
fn: () => Question.list(), fn: () => list(),
}) })
expect(onePending.length).toBe(1) expect(onePending.length).toBe(1)
@@ -371,11 +382,11 @@ test("questions stay isolated by directory", async () => {
await Instance.provide({ await Instance.provide({
directory: one.path, directory: one.path,
fn: () => Question.reject(onePending[0].id), fn: () => reject(onePending[0].id),
}) })
await Instance.provide({ await Instance.provide({
directory: two.path, directory: two.path,
fn: () => Question.reject(twoPending[0].id), fn: () => reject(twoPending[0].id),
}) })
await p1.catch(() => {}) await p1.catch(() => {})
@@ -385,10 +396,10 @@ test("questions stay isolated by directory", async () => {
test("pending question rejects on instance dispose", async () => { test("pending question rejects on instance dispose", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const ask = Instance.provide({ const pending = Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: () => { fn: () => {
return Question.ask({ return ask({
sessionID: SessionID.make("ses_dispose"), sessionID: SessionID.make("ses_dispose"),
questions: [ questions: [
{ {
@@ -400,7 +411,7 @@ test("pending question rejects on instance dispose", async () => {
}) })
}, },
}) })
const result = ask.then( const result = pending.then(
() => "resolved" as const, () => "resolved" as const,
(err) => err, (err) => err,
) )
@@ -408,8 +419,8 @@ test("pending question rejects on instance dispose", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const pending = await Question.list() const items = await list()
expect(pending).toHaveLength(1) expect(items).toHaveLength(1)
await Instance.dispose() await Instance.dispose()
}, },
}) })
@@ -420,10 +431,10 @@ test("pending question rejects on instance dispose", async () => {
test("pending question rejects on instance reload", async () => { test("pending question rejects on instance reload", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const ask = Instance.provide({ const pending = Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: () => { fn: () => {
return Question.ask({ return ask({
sessionID: SessionID.make("ses_reload"), sessionID: SessionID.make("ses_reload"),
questions: [ questions: [
{ {
@@ -435,7 +446,7 @@ test("pending question rejects on instance reload", async () => {
}) })
}, },
}) })
const result = ask.then( const result = pending.then(
() => "resolved" as const, () => "resolved" as const,
(err) => err, (err) => err,
) )
@@ -443,8 +454,8 @@ test("pending question rejects on instance reload", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const pending = await Question.list() const items = await list()
expect(pending).toHaveLength(1) expect(items).toHaveLength(1)
await Instance.reload({ directory: tmp.path }) await Instance.reload({ directory: tmp.path })
}, },
}) })
@@ -1,11 +1,9 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { Effect } from "effect"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { Session } from "../../src/session" import { Session } from "../../src/session"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { SessionPrompt } from "../../src/session/prompt" import { SessionPrompt } from "../../src/session/prompt"
import { SessionRunState } from "../../src/session/run-state"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
@@ -16,25 +14,6 @@ afterEach(async () => {
await Instance.disposeAll() await Instance.disposeAll()
}) })
async function user(sessionID: SessionID, text: string) {
const msg = await Session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
await Session.updatePart({
id: PartID.ascending(),
sessionID,
messageID: msg.id,
type: "text",
text,
})
return msg
}
describe("session action routes", () => { describe("session action routes", () => {
test("abort route calls SessionPrompt.cancel", async () => { test("abort route calls SessionPrompt.cancel", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
@@ -45,9 +24,7 @@ describe("session action routes", () => {
const cancel = spyOn(SessionPrompt, "cancel").mockResolvedValue() const cancel = spyOn(SessionPrompt, "cancel").mockResolvedValue()
const app = Server.Default().app const app = Server.Default().app
const res = await app.request(`/session/${session.id}/abort`, { const res = await app.request(`/session/${session.id}/abort`, { method: "POST" })
method: "POST",
})
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toBe(true) expect(await res.json()).toBe(true)
@@ -57,28 +34,4 @@ describe("session action routes", () => {
}, },
}) })
}) })
test("delete message route returns 400 when session is busy", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const msg = await user(session.id, "hello")
const busy = spyOn(SessionRunState, "assertNotBusy").mockRejectedValue(new Session.BusyError(session.id))
const remove = spyOn(Session, "removeMessage").mockResolvedValue(msg.id)
const app = Server.Default().app
const res = await app.request(`/session/${session.id}/message/${msg.id}`, {
method: "DELETE",
})
expect(res.status).toBe(400)
expect(busy).toHaveBeenCalledWith(session.id)
expect(remove).not.toHaveBeenCalled()
await Session.remove(session.id)
},
})
})
}) })
@@ -147,7 +147,7 @@ describe("session messages endpoint", () => {
describe("session.prompt_async error handling", () => { describe("session.prompt_async error handling", () => {
test("prompt_async route has error handler for detached prompt call", async () => { test("prompt_async route has error handler for detached prompt call", async () => {
const src = await Bun.file(new URL("../../src/server/routes/session.ts", import.meta.url)).text() const src = await Bun.file(new URL("../../src/server/instance/session.ts", import.meta.url)).text()
const start = src.indexOf('"/:sessionID/prompt_async"') const start = src.indexOf('"/:sessionID/prompt_async"')
const end = src.indexOf('"/:sessionID/command"', start) const end = src.indexOf('"/:sessionID/command"', start)
expect(start).toBeGreaterThan(-1) expect(start).toBeGreaterThan(-1)
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test" import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { Effect } from "effect"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instruction } from "../../src/session/instruction" import { Instruction } from "../../src/session/instruction"
import type { MessageV2 } from "../../src/session/message-v2" import type { MessageV2 } from "../../src/session/message-v2"
@@ -8,6 +9,9 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { Global } from "../../src/global" import { Global } from "../../src/global"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
const run = <A>(effect: Effect.Effect<A, any, Instruction.Service>) =>
Effect.runPromise(effect.pipe(Effect.provide(Instruction.defaultLayer)))
function loaded(filepath: string): MessageV2.WithParts[] { function loaded(filepath: string): MessageV2.WithParts[] {
const sessionID = SessionID.make("session-loaded-1") const sessionID = SessionID.make("session-loaded-1")
const messageID = MessageID.make("message-loaded-1") const messageID = MessageID.make("message-loaded-1")
@@ -57,17 +61,22 @@ describe("Instruction.resolve", () => {
}) })
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: () =>
const system = await Instruction.systemPaths() run(
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true) Instruction.Service.use((svc) =>
Effect.gen(function* () {
const system = yield* svc.systemPaths()
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
const results = await Instruction.resolve( const results = yield* svc.resolve(
[], [],
path.join(tmp.path, "src", "file.ts"), path.join(tmp.path, "src", "file.ts"),
MessageID.make("message-test-1"), MessageID.make("message-test-1"),
) )
expect(results).toEqual([]) expect(results).toEqual([])
}, }),
),
),
}) })
}) })
@@ -80,18 +89,23 @@ describe("Instruction.resolve", () => {
}) })
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: () =>
const system = await Instruction.systemPaths() run(
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false) Instruction.Service.use((svc) =>
Effect.gen(function* () {
const system = yield* svc.systemPaths()
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
const results = await Instruction.resolve( const results = yield* svc.resolve(
[], [],
path.join(tmp.path, "subdir", "nested", "file.ts"), path.join(tmp.path, "subdir", "nested", "file.ts"),
MessageID.make("message-test-2"), MessageID.make("message-test-2"),
) )
expect(results.length).toBe(1) expect(results.length).toBe(1)
expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
}, }),
),
),
}) })
}) })
@@ -104,14 +118,19 @@ describe("Instruction.resolve", () => {
}) })
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: () =>
const filepath = path.join(tmp.path, "subdir", "AGENTS.md") run(
const system = await Instruction.systemPaths() Instruction.Service.use((svc) =>
expect(system.has(filepath)).toBe(false) Effect.gen(function* () {
const filepath = path.join(tmp.path, "subdir", "AGENTS.md")
const system = yield* svc.systemPaths()
expect(system.has(filepath)).toBe(false)
const results = await Instruction.resolve([], filepath, MessageID.make("message-test-3")) const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3"))
expect(results).toEqual([]) expect(results).toEqual([])
}, }),
),
),
}) })
}) })
@@ -124,17 +143,22 @@ describe("Instruction.resolve", () => {
}) })
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: () =>
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts") run(
const id = MessageID.make("message-claim-1") Instruction.Service.use((svc) =>
Effect.gen(function* () {
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-1")
const first = await Instruction.resolve([], filepath, id) const first = yield* svc.resolve([], filepath, id)
const second = await Instruction.resolve([], filepath, id) const second = yield* svc.resolve([], filepath, id)
expect(first).toHaveLength(1) expect(first).toHaveLength(1)
expect(first[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) expect(first[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
expect(second).toEqual([]) expect(second).toEqual([])
}, }),
),
),
}) })
}) })
@@ -147,18 +171,23 @@ describe("Instruction.resolve", () => {
}) })
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: () =>
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts") run(
const id = MessageID.make("message-claim-2") Instruction.Service.use((svc) =>
Effect.gen(function* () {
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-2")
const first = await Instruction.resolve([], filepath, id) const first = yield* svc.resolve([], filepath, id)
await Instruction.clear(id) yield* svc.clear(id)
const second = await Instruction.resolve([], filepath, id) const second = yield* svc.resolve([], filepath, id)
expect(first).toHaveLength(1) expect(first).toHaveLength(1)
expect(second).toHaveLength(1) expect(second).toHaveLength(1)
expect(second[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) expect(second[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
}, }),
),
),
}) })
}) })
@@ -171,15 +200,19 @@ describe("Instruction.resolve", () => {
}) })
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: () =>
const agents = path.join(tmp.path, "subdir", "AGENTS.md") run(
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts") Instruction.Service.use((svc) =>
const id = MessageID.make("message-claim-3") Effect.gen(function* () {
const agents = path.join(tmp.path, "subdir", "AGENTS.md")
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-3")
const results = await Instruction.resolve(loaded(agents), filepath, id) const results = yield* svc.resolve(loaded(agents), filepath, id)
expect(results).toEqual([])
expect(results).toEqual([]) }),
}, ),
),
}) })
}) })
@@ -221,11 +254,16 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
try { try {
await Instance.provide({ await Instance.provide({
directory: projectTmp.path, directory: projectTmp.path,
fn: async () => { fn: () =>
const paths = await Instruction.systemPaths() run(
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true) Instruction.Service.use((svc) =>
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false) Effect.gen(function* () {
}, const paths = yield* svc.systemPaths()
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true)
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false)
}),
),
),
}) })
} finally { } finally {
;(Global.Path as { config: string }).config = originalGlobalConfig ;(Global.Path as { config: string }).config = originalGlobalConfig
@@ -248,11 +286,16 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
try { try {
await Instance.provide({ await Instance.provide({
directory: projectTmp.path, directory: projectTmp.path,
fn: async () => { fn: () =>
const paths = await Instruction.systemPaths() run(
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false) Instruction.Service.use((svc) =>
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) Effect.gen(function* () {
}, const paths = yield* svc.systemPaths()
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false)
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
}),
),
),
}) })
} finally { } finally {
;(Global.Path as { config: string }).config = originalGlobalConfig ;(Global.Path as { config: string }).config = originalGlobalConfig
@@ -274,10 +317,15 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
try { try {
await Instance.provide({ await Instance.provide({
directory: projectTmp.path, directory: projectTmp.path,
fn: async () => { fn: () =>
const paths = await Instruction.systemPaths() run(
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) Instruction.Service.use((svc) =>
}, Effect.gen(function* () {
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
}),
),
),
}) })
} finally { } finally {
;(Global.Path as { config: string }).config = originalGlobalConfig ;(Global.Path as { config: string }).config = originalGlobalConfig
@@ -31,6 +31,7 @@ import { SessionRunState } from "../../src/session/run-state"
import { MessageID, PartID, SessionID } from "../../src/session/schema" import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status" import { SessionStatus } from "../../src/session/status"
import { Skill } from "../../src/skill" import { Skill } from "../../src/skill"
import { SystemPrompt } from "../../src/session/system"
import { Shell } from "../../src/shell/shell" import { Shell } from "../../src/shell/shell"
import { Snapshot } from "../../src/snapshot" import { Snapshot } from "../../src/snapshot"
import { ToolRegistry } from "../../src/tool/registry" import { ToolRegistry } from "../../src/tool/registry"
@@ -193,6 +194,7 @@ function makeHttp() {
Layer.provideMerge(registry), Layer.provideMerge(registry),
Layer.provideMerge(trunc), Layer.provideMerge(trunc),
Layer.provide(Instruction.defaultLayer), Layer.provide(Instruction.defaultLayer),
Layer.provide(SystemPrompt.defaultLayer),
Layer.provideMerge(deps), Layer.provideMerge(deps),
), ),
) )
@@ -41,6 +41,7 @@ import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "../../src/provider/provider" import { Provider as ProviderSvc } from "../../src/provider/provider"
import { Question } from "../../src/question" import { Question } from "../../src/question"
import { Skill } from "../../src/skill" import { Skill } from "../../src/skill"
import { SystemPrompt } from "../../src/session/system"
import { Todo } from "../../src/session/todo" import { Todo } from "../../src/session/todo"
import { SessionCompaction } from "../../src/session/compaction" import { SessionCompaction } from "../../src/session/compaction"
import { Instruction } from "../../src/session/instruction" import { Instruction } from "../../src/session/instruction"
@@ -157,6 +158,7 @@ function makeHttp() {
Layer.provideMerge(registry), Layer.provideMerge(registry),
Layer.provideMerge(trunc), Layer.provideMerge(trunc),
Layer.provide(Instruction.defaultLayer), Layer.provide(Instruction.defaultLayer),
Layer.provide(SystemPrompt.defaultLayer),
Layer.provideMerge(deps), Layer.provideMerge(deps),
), ),
) )
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { Effect } from "effect"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { SystemPrompt } from "../../src/session/system" import { SystemPrompt } from "../../src/session/system"
@@ -38,8 +39,13 @@ description: ${description}
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const build = await Agent.get("build") const build = await Agent.get("build")
const first = await SystemPrompt.skills(build!) const runSkills = Effect.gen(function* () {
const second = await SystemPrompt.skills(build!) const svc = yield* SystemPrompt.Service
return yield* svc.skills(build!)
}).pipe(Effect.provide(SystemPrompt.defaultLayer))
const first = await Effect.runPromise(runSkills)
const second = await Effect.runPromise(runSkills)
expect(first).toBe(second) expect(first).toBe(second)
@@ -7,12 +7,21 @@ import { Instance } from "../../src/project/instance"
import { LSP } from "../../src/lsp" import { LSP } from "../../src/lsp"
import { AppFileSystem } from "../../src/filesystem" import { AppFileSystem } from "../../src/filesystem"
import { Format } from "../../src/format" import { Format } from "../../src/format"
import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { Truncate } from "../../src/tool/truncate"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
const runtime = ManagedRuntime.make( const runtime = ManagedRuntime.make(
Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer), Layer.mergeAll(
LSP.defaultLayer,
AppFileSystem.defaultLayer,
Format.defaultLayer,
Bus.layer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
) )
const baseCtx = { const baseCtx = {
@@ -50,7 +59,7 @@ type ToolCtx = typeof baseCtx & {
const execute = async (params: { patchText: string }, ctx: ToolCtx) => { const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
const info = await runtime.runPromise(ApplyPatchTool) const info = await runtime.runPromise(ApplyPatchTool)
const tool = await info.init() const tool = await runtime.runPromise(info.init())
return Effect.runPromise(tool.execute(params, ctx)) return Effect.runPromise(tool.execute(params, ctx))
} }
+9 -2
View File
@@ -8,6 +8,7 @@ import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import type { Permission } from "../../src/permission" import type { Permission } from "../../src/permission"
import { Agent } from "../../src/agent/agent"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool/truncate"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
@@ -15,11 +16,17 @@ import { AppFileSystem } from "../../src/filesystem"
import { Plugin } from "../../src/plugin" import { Plugin } from "../../src/plugin"
const runtime = ManagedRuntime.make( const runtime = ManagedRuntime.make(
Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, Plugin.defaultLayer), Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer,
Plugin.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
) )
function initBash() { function initBash() {
return runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init())))) return runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => info.init())))
} }
const ctx = { const ctx = {
+12 -2
View File
@@ -9,8 +9,10 @@ import { FileTime } from "../../src/file/time"
import { LSP } from "../../src/lsp" import { LSP } from "../../src/lsp"
import { AppFileSystem } from "../../src/filesystem" import { AppFileSystem } from "../../src/filesystem"
import { Format } from "../../src/format" import { Format } from "../../src/format"
import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event" import { BusEvent } from "../../src/bus/bus-event"
import { Truncate } from "../../src/tool/truncate"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
const ctx = { const ctx = {
@@ -34,7 +36,15 @@ async function touch(file: string, time: number) {
} }
const runtime = ManagedRuntime.make( const runtime = ManagedRuntime.make(
Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer), Layer.mergeAll(
LSP.defaultLayer,
FileTime.defaultLayer,
AppFileSystem.defaultLayer,
Format.defaultLayer,
Bus.layer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
) )
afterAll(async () => { afterAll(async () => {
@@ -45,7 +55,7 @@ const resolve = () =>
runtime.runPromise( runtime.runPromise(
Effect.gen(function* () { Effect.gen(function* () {
const info = yield* EditTool const info = yield* EditTool
return yield* Effect.promise(() => info.init()) return yield* info.init()
}), }),
) )
+6 -2
View File
@@ -6,11 +6,15 @@ import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Truncate } from "../../src/tool/truncate"
import { Agent } from "../../src/agent/agent"
const runtime = ManagedRuntime.make(Layer.mergeAll(CrossSpawnSpawner.defaultLayer)) const runtime = ManagedRuntime.make(
Layer.mergeAll(CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
)
function initGrep() { function initGrep() {
return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init())))) return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => info.init())))
} }
const ctx = { const ctx = {
+7 -3
View File
@@ -4,7 +4,9 @@ import { Tool } from "../../src/tool/tool"
import { QuestionTool } from "../../src/tool/question" import { QuestionTool } from "../../src/tool/question"
import { Question } from "../../src/question" import { Question } from "../../src/question"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import { Agent } from "../../src/agent/agent"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Truncate } from "../../src/tool/truncate"
import { provideTmpdirInstance } from "../fixture/fixture" import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@@ -19,7 +21,9 @@ const ctx = {
ask: () => Effect.void, ask: () => Effect.void,
} }
const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer)) const it = testEffect(
Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
)
const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) { const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) {
for (;;) { for (;;) {
@@ -36,7 +40,7 @@ describe("tool.question", () => {
Effect.gen(function* () { Effect.gen(function* () {
const question = yield* Question.Service const question = yield* Question.Service
const toolInfo = yield* QuestionTool const toolInfo = yield* QuestionTool
const tool = yield* Effect.promise(() => toolInfo.init()) const tool = yield* toolInfo.init()
const questions = [ const questions = [
{ {
question: "What is your favorite color?", question: "What is your favorite color?",
@@ -64,7 +68,7 @@ describe("tool.question", () => {
Effect.gen(function* () { Effect.gen(function* () {
const question = yield* Question.Service const question = yield* Question.Service
const toolInfo = yield* QuestionTool const toolInfo = yield* QuestionTool
const tool = yield* Effect.promise(() => toolInfo.init()) const tool = yield* toolInfo.init()
const questions = [ const questions = [
{ {
question: "What is your favorite animal?", question: "What is your favorite animal?",
+3 -1
View File
@@ -11,6 +11,7 @@ import { Instance } from "../../src/project/instance"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import { Instruction } from "../../src/session/instruction" import { Instruction } from "../../src/session/instruction"
import { ReadTool } from "../../src/tool/read" import { ReadTool } from "../../src/tool/read"
import { Truncate } from "../../src/tool/truncate"
import { Tool } from "../../src/tool/tool" import { Tool } from "../../src/tool/tool"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { provideInstance, tmpdirScoped } from "../fixture/fixture" import { provideInstance, tmpdirScoped } from "../fixture/fixture"
@@ -41,12 +42,13 @@ const it = testEffect(
FileTime.defaultLayer, FileTime.defaultLayer,
Instruction.defaultLayer, Instruction.defaultLayer,
LSP.defaultLayer, LSP.defaultLayer,
Truncate.defaultLayer,
), ),
) )
const init = Effect.fn("ReadToolTest.init")(function* () { const init = Effect.fn("ReadToolTest.init")(function* () {
const info = yield* ReadTool const info = yield* ReadTool
return yield* Effect.promise(() => info.init()) return yield* info.init()
}) })
const run = Effect.fn("ReadToolTest.run")(function* ( const run = Effect.fn("ReadToolTest.run")(function* (
+6 -2
View File
@@ -1,6 +1,8 @@
import { Effect, Layer, ManagedRuntime } from "effect" import { Effect, Layer, ManagedRuntime } from "effect"
import { Agent } from "../../src/agent/agent"
import { Skill } from "../../src/skill" import { Skill } from "../../src/skill"
import { Ripgrep } from "../../src/file/ripgrep" import { Ripgrep } from "../../src/file/ripgrep"
import { Truncate } from "../../src/tool/truncate"
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
@@ -150,9 +152,11 @@ Use this skill.
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const runtime = ManagedRuntime.make(Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer)) const runtime = ManagedRuntime.make(
Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
)
const info = await runtime.runPromise(SkillTool) const info = await runtime.runPromise(SkillTool)
const tool = await info.init() const tool = await runtime.runPromise(info.init())
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = { const ctx: Tool.Context = {
...baseCtx, ...baseCtx,
+6 -4
View File
@@ -10,6 +10,7 @@ import type { SessionPrompt } from "../../src/session/prompt"
import { MessageID, PartID } from "../../src/session/schema" import { MessageID, PartID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { TaskTool, type TaskPromptOps } from "../../src/tool/task" import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
import { Truncate } from "../../src/tool/truncate"
import { ToolRegistry } from "../../src/tool/registry" import { ToolRegistry } from "../../src/tool/registry"
import { provideTmpdirInstance } from "../fixture/fixture" import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@@ -29,6 +30,7 @@ const it = testEffect(
Config.defaultLayer, Config.defaultLayer,
CrossSpawnSpawner.defaultLayer, CrossSpawnSpawner.defaultLayer,
Session.defaultLayer, Session.defaultLayer,
Truncate.defaultLayer,
ToolRegistry.defaultLayer, ToolRegistry.defaultLayer,
), ),
) )
@@ -191,7 +193,7 @@ describe("tool.task", () => {
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" }) const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
const tool = yield* TaskTool const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init()) const def = yield* tool.init()
let seen: SessionPrompt.PromptInput | undefined let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) }) const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
@@ -229,7 +231,7 @@ describe("tool.task", () => {
Effect.gen(function* () { Effect.gen(function* () {
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const tool = yield* TaskTool const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init()) const def = yield* tool.init()
const calls: unknown[] = [] const calls: unknown[] = []
const promptOps = stubOps() const promptOps = stubOps()
@@ -278,7 +280,7 @@ describe("tool.task", () => {
const sessions = yield* Session.Service const sessions = yield* Session.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const tool = yield* TaskTool const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init()) const def = yield* tool.init()
let seen: SessionPrompt.PromptInput | undefined let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) }) const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
@@ -318,7 +320,7 @@ describe("tool.task", () => {
const sessions = yield* Session.Service const sessions = yield* Session.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const tool = yield* TaskTool const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init()) const def = yield* tool.init()
let seen: SessionPrompt.PromptInput | undefined let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
+17 -13
View File
@@ -1,7 +1,11 @@
import { describe, test, expect } from "bun:test" import { describe, test, expect } from "bun:test"
import { Effect } from "effect" import { Effect, Layer, ManagedRuntime } from "effect"
import z from "zod" import z from "zod"
import { Agent } from "../../src/agent/agent"
import { Tool } from "../../src/tool/tool" import { Tool } from "../../src/tool/tool"
import { Truncate } from "../../src/tool/truncate"
const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer))
const params = z.object({ input: z.string() }) const params = z.object({ input: z.string() })
@@ -21,34 +25,34 @@ describe("Tool.define", () => {
const original = makeTool("test") const original = makeTool("test")
const originalExecute = original.execute const originalExecute = original.execute
const info = await Effect.runPromise(Tool.define("test-tool", Effect.succeed(original))) const info = await runtime.runPromise(Tool.define("test-tool", Effect.succeed(original)))
await info.init() await Effect.runPromise(info.init())
await info.init() await Effect.runPromise(info.init())
await info.init() await Effect.runPromise(info.init())
expect(original.execute).toBe(originalExecute) expect(original.execute).toBe(originalExecute)
}) })
test("function-defined tool returns fresh objects and is unaffected", async () => { test("effect-defined tool returns fresh objects and is unaffected", async () => {
const info = await Effect.runPromise( const info = await runtime.runPromise(
Tool.define( Tool.define(
"test-fn-tool", "test-fn-tool",
Effect.succeed(() => Promise.resolve(makeTool("test"))), Effect.succeed(() => Effect.succeed(makeTool("test"))),
), ),
) )
const first = await info.init() const first = await Effect.runPromise(info.init())
const second = await info.init() const second = await Effect.runPromise(info.init())
expect(first).not.toBe(second) expect(first).not.toBe(second)
}) })
test("object-defined tool returns distinct objects per init() call", async () => { test("object-defined tool returns distinct objects per init() call", async () => {
const info = await Effect.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test")))) const info = await runtime.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test"))))
const first = await info.init() const first = await Effect.runPromise(info.init())
const second = await info.init() const second = await Effect.runPromise(info.init())
expect(first).not.toBe(second) expect(first).not.toBe(second)
}) })
+123 -88
View File
@@ -1,7 +1,7 @@
import { describe, test, expect } from "bun:test" import { describe, test, expect } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { Effect, FileSystem, Layer } from "effect" import { Effect, FileSystem, Layer } from "effect"
import { Truncate, Truncate as TruncateSvc } from "../../src/tool/truncate" import { Truncate } from "../../src/tool/truncate"
import { Identifier } from "../../src/id/id" import { Identifier } from "../../src/id/id"
import { Process } from "../../src/util/process" import { Process } from "../../src/util/process"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
@@ -12,120 +12,155 @@ import { writeFileStringScoped } from "../lib/filesystem"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
const ROOT = path.resolve(import.meta.dir, "..", "..") const ROOT = path.resolve(import.meta.dir, "..", "..")
const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer))
describe("Truncate", () => { describe("Truncate", () => {
describe("output", () => { describe("output", () => {
test("truncates large json file by bytes", async () => { it.live("truncates large json file by bytes", () =>
const content = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")) Effect.gen(function* () {
const result = await Truncate.output(content) const svc = yield* Truncate.Service
const content = yield* Effect.promise(() => Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")))
const result = yield* svc.output(content)
expect(result.truncated).toBe(true) expect(result.truncated).toBe(true)
expect(result.content).toContain("truncated...") expect(result.content).toContain("truncated...")
if (result.truncated) expect(result.outputPath).toBeDefined() if (result.truncated) expect(result.outputPath).toBeDefined()
}) }),
)
test("returns content unchanged when under limits", async () => { it.live("returns content unchanged when under limits", () =>
const content = "line1\nline2\nline3" Effect.gen(function* () {
const result = await Truncate.output(content) const svc = yield* Truncate.Service
const content = "line1\nline2\nline3"
const result = yield* svc.output(content)
expect(result.truncated).toBe(false) expect(result.truncated).toBe(false)
expect(result.content).toBe(content) expect(result.content).toBe(content)
}) }),
)
test("truncates by line count", async () => { it.live("truncates by line count", () =>
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") Effect.gen(function* () {
const result = await Truncate.output(lines, { maxLines: 10 }) const svc = yield* Truncate.Service
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const result = yield* svc.output(lines, { maxLines: 10 })
expect(result.truncated).toBe(true) expect(result.truncated).toBe(true)
expect(result.content).toContain("...90 lines truncated...") expect(result.content).toContain("...90 lines truncated...")
}) }),
)
test("truncates by byte count", async () => { it.live("truncates by byte count", () =>
const content = "a".repeat(1000) Effect.gen(function* () {
const result = await Truncate.output(content, { maxBytes: 100 }) const svc = yield* Truncate.Service
const content = "a".repeat(1000)
const result = yield* svc.output(content, { maxBytes: 100 })
expect(result.truncated).toBe(true) expect(result.truncated).toBe(true)
expect(result.content).toContain("truncated...") expect(result.content).toContain("truncated...")
}) }),
)
test("truncates from head by default", async () => { it.live("truncates from head by default", () =>
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n") Effect.gen(function* () {
const result = await Truncate.output(lines, { maxLines: 3 }) const svc = yield* Truncate.Service
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
const result = yield* svc.output(lines, { maxLines: 3 })
expect(result.truncated).toBe(true) expect(result.truncated).toBe(true)
expect(result.content).toContain("line0") expect(result.content).toContain("line0")
expect(result.content).toContain("line1") expect(result.content).toContain("line1")
expect(result.content).toContain("line2") expect(result.content).toContain("line2")
expect(result.content).not.toContain("line9") expect(result.content).not.toContain("line9")
}) }),
)
test("truncates from tail when direction is tail", async () => { it.live("truncates from tail when direction is tail", () =>
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n") Effect.gen(function* () {
const result = await Truncate.output(lines, { maxLines: 3, direction: "tail" }) const svc = yield* Truncate.Service
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
const result = yield* svc.output(lines, { maxLines: 3, direction: "tail" })
expect(result.truncated).toBe(true) expect(result.truncated).toBe(true)
expect(result.content).toContain("line7") expect(result.content).toContain("line7")
expect(result.content).toContain("line8") expect(result.content).toContain("line8")
expect(result.content).toContain("line9") expect(result.content).toContain("line9")
expect(result.content).not.toContain("line0") expect(result.content).not.toContain("line0")
}) }),
)
test("uses default MAX_LINES and MAX_BYTES", () => { test("uses default MAX_LINES and MAX_BYTES", () => {
expect(Truncate.MAX_LINES).toBe(2000) expect(Truncate.MAX_LINES).toBe(2000)
expect(Truncate.MAX_BYTES).toBe(50 * 1024) expect(Truncate.MAX_BYTES).toBe(50 * 1024)
}) })
test("large single-line file truncates with byte message", async () => { it.live("large single-line file truncates with byte message", () =>
const content = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")) Effect.gen(function* () {
const result = await Truncate.output(content) const svc = yield* Truncate.Service
const content = yield* Effect.promise(() => Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")))
const result = yield* svc.output(content)
expect(result.truncated).toBe(true) expect(result.truncated).toBe(true)
expect(result.content).toContain("bytes truncated...") expect(result.content).toContain("bytes truncated...")
expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES) expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
}) }),
)
test("writes full output to file when truncated", async () => { it.live("writes full output to file when truncated", () =>
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") Effect.gen(function* () {
const result = await Truncate.output(lines, { maxLines: 10 }) const svc = yield* Truncate.Service
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const result = yield* svc.output(lines, { maxLines: 10 })
expect(result.truncated).toBe(true) expect(result.truncated).toBe(true)
expect(result.content).toContain("The tool call succeeded but the output was truncated") expect(result.content).toContain("The tool call succeeded but the output was truncated")
expect(result.content).toContain("Grep") expect(result.content).toContain("Grep")
if (!result.truncated) throw new Error("expected truncated") if (!result.truncated) throw new Error("expected truncated")
expect(result.outputPath).toBeDefined() expect(result.outputPath).toBeDefined()
expect(result.outputPath).toContain("tool_") expect(result.outputPath).toContain("tool_")
const written = await Filesystem.readText(result.outputPath!) const written = yield* Effect.promise(() => Filesystem.readText(result.outputPath!))
expect(written).toBe(lines) expect(written).toBe(lines)
}) }),
)
test("suggests Task tool when agent has task permission", async () => { it.live("suggests Task tool when agent has task permission", () =>
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") Effect.gen(function* () {
const agent = { permission: [{ permission: "task", pattern: "*", action: "allow" as const }] } const svc = yield* Truncate.Service
const result = await Truncate.output(lines, { maxLines: 10 }, agent as any) const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const agent = { permission: [{ permission: "task", pattern: "*", action: "allow" as const }] }
const result = yield* svc.output(lines, { maxLines: 10 }, agent as any)
expect(result.truncated).toBe(true) expect(result.truncated).toBe(true)
expect(result.content).toContain("Grep") expect(result.content).toContain("Grep")
expect(result.content).toContain("Task tool") expect(result.content).toContain("Task tool")
}) }),
)
test("omits Task tool hint when agent lacks task permission", async () => { it.live("omits Task tool hint when agent lacks task permission", () =>
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") Effect.gen(function* () {
const agent = { permission: [{ permission: "task", pattern: "*", action: "deny" as const }] } const svc = yield* Truncate.Service
const result = await Truncate.output(lines, { maxLines: 10 }, agent as any) const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const agent = { permission: [{ permission: "task", pattern: "*", action: "deny" as const }] }
const result = yield* svc.output(lines, { maxLines: 10 }, agent as any)
expect(result.truncated).toBe(true) expect(result.truncated).toBe(true)
expect(result.content).toContain("Grep") expect(result.content).toContain("Grep")
expect(result.content).not.toContain("Task tool") expect(result.content).not.toContain("Task tool")
}) }),
)
test("does not write file when not truncated", async () => { it.live("does not write file when not truncated", () =>
const content = "short content" Effect.gen(function* () {
const result = await Truncate.output(content) const svc = yield* Truncate.Service
const content = "short content"
const result = yield* svc.output(content)
expect(result.truncated).toBe(false) expect(result.truncated).toBe(false)
if (result.truncated) throw new Error("expected not truncated") if (result.truncated) throw new Error("expected not truncated")
expect("outputPath" in result).toBe(false) expect("outputPath" in result).toBe(false)
}) }),
)
test("loads truncate effect in a fresh process", async () => { test("loads truncate effect in a fresh process", async () => {
const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], { const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], {
@@ -138,10 +173,10 @@ describe("Truncate", () => {
describe("cleanup", () => { describe("cleanup", () => {
const DAY_MS = 24 * 60 * 60 * 1000 const DAY_MS = 24 * 60 * 60 * 1000
const it = testEffect(Layer.mergeAll(TruncateSvc.defaultLayer, NodeFileSystem.layer))
it.live("deletes files older than 7 days and preserves recent files", () => it.live("deletes files older than 7 days and preserves recent files", () =>
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Truncate.Service
const fs = yield* FileSystem.FileSystem const fs = yield* FileSystem.FileSystem
yield* fs.makeDirectory(Truncate.DIR, { recursive: true }) yield* fs.makeDirectory(Truncate.DIR, { recursive: true })
@@ -151,7 +186,7 @@ describe("Truncate", () => {
yield* writeFileStringScoped(old, "old content") yield* writeFileStringScoped(old, "old content")
yield* writeFileStringScoped(recent, "recent content") yield* writeFileStringScoped(recent, "recent content")
yield* TruncateSvc.Service.use((s) => s.cleanup()) yield* svc.cleanup()
expect(yield* fs.exists(old)).toBe(false) expect(yield* fs.exists(old)).toBe(false)
expect(yield* fs.exists(recent)).toBe(true) expect(yield* fs.exists(recent)).toBe(true)
+10 -16
View File
@@ -1,7 +1,9 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { Effect } from "effect" import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http" import { FetchHttpClient } from "effect/unstable/http"
import { Agent } from "../../src/agent/agent"
import { Truncate } from "../../src/tool/truncate"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { WebFetchTool } from "../../src/tool/webfetch" import { WebFetchTool } from "../../src/tool/webfetch"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
@@ -24,10 +26,11 @@ async function withFetch(fetch: (req: Request) => Response | Promise<Response>,
await fn(server.url) await fn(server.url)
} }
function initTool() { function exec(args: { url: string; format: "text" | "markdown" | "html" }) {
return WebFetchTool.pipe( return WebFetchTool.pipe(
Effect.flatMap((info) => Effect.promise(() => info.init())), Effect.flatMap((info) => info.init()),
Effect.provide(FetchHttpClient.layer), Effect.flatMap((tool) => tool.execute(args, ctx)),
Effect.provide(Layer.mergeAll(FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer)),
Effect.runPromise, Effect.runPromise,
) )
} }
@@ -41,10 +44,7 @@ describe("tool.webfetch", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const webfetch = await initTool() const result = await exec({ url: new URL("/image.png", url).toString(), format: "markdown" })
const result = await Effect.runPromise(
webfetch.execute({ url: new URL("/image.png", url).toString(), format: "markdown" }, ctx),
)
expect(result.output).toBe("Image fetched successfully") expect(result.output).toBe("Image fetched successfully")
expect(result.attachments).toBeDefined() expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1) expect(result.attachments?.length).toBe(1)
@@ -72,10 +72,7 @@ describe("tool.webfetch", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const webfetch = await initTool() const result = await exec({ url: new URL("/image.svg", url).toString(), format: "html" })
const result = await Effect.runPromise(
webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx),
)
expect(result.output).toContain("<svg") expect(result.output).toContain("<svg")
expect(result.attachments).toBeUndefined() expect(result.attachments).toBeUndefined()
}, },
@@ -95,10 +92,7 @@ describe("tool.webfetch", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const webfetch = await initTool() const result = await exec({ url: new URL("/file.txt", url).toString(), format: "text" })
const result = await Effect.runPromise(
webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx),
)
expect(result.output).toBe("hello from webfetch") expect(result.output).toBe("hello from webfetch")
expect(result.attachments).toBeUndefined() expect(result.attachments).toBeUndefined()
}, },
+5 -1
View File
@@ -9,7 +9,9 @@ import { AppFileSystem } from "../../src/filesystem"
import { FileTime } from "../../src/file/time" import { FileTime } from "../../src/file/time"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { Format } from "../../src/format" import { Format } from "../../src/format"
import { Truncate } from "../../src/tool/truncate"
import { Tool } from "../../src/tool/tool" import { Tool } from "../../src/tool/tool"
import { Agent } from "../../src/agent/agent"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { provideTmpdirInstance } from "../fixture/fixture" import { provideTmpdirInstance } from "../fixture/fixture"
@@ -38,12 +40,14 @@ const it = testEffect(
Bus.layer, Bus.layer,
Format.defaultLayer, Format.defaultLayer,
CrossSpawnSpawner.defaultLayer, CrossSpawnSpawner.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
), ),
) )
const init = Effect.fn("WriteToolTest.init")(function* () { const init = Effect.fn("WriteToolTest.init")(function* () {
const info = yield* WriteTool const info = yield* WriteTool
return yield* Effect.promise(() => info.init()) return yield* info.init()
}) })
const run = Effect.fn("WriteToolTest.run")(function* ( const run = Effect.fn("WriteToolTest.run")(function* (
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Global } from "../../src/global"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
const log = Global.Path.log
afterEach(() => {
Global.Path.log = log
})
async function files(dir: string) {
let last = ""
let same = 0
for (let i = 0; i < 50; i++) {
const list = (await fs.readdir(dir)).sort()
const next = JSON.stringify(list)
same = next === last ? same + 1 : 0
if (same >= 2 && list.length === 11) return list
last = next
await Bun.sleep(10)
}
return (await fs.readdir(dir)).sort()
}
test("init cleanup keeps the newest timestamped logs", async () => {
await using tmp = await tmpdir()
Global.Path.log = tmp.path
const list = Array.from({ length: 12 }, (_, i) => `2000-01-${String(i + 1).padStart(2, "0")}T000000.log`)
await Promise.all(list.map((file) => fs.writeFile(path.join(tmp.path, file), file)))
await Log.init({ print: false, dev: false })
const next = await files(tmp.path)
expect(next).not.toContain(list[0]!)
expect(next).toContain(list.at(-1)!)
})
+2
View File
@@ -1725,6 +1725,7 @@ export class Session2 extends HeyApiClient {
directory?: string directory?: string
workspace?: string workspace?: string
title?: string title?: string
permission?: PermissionRuleset
time?: { time?: {
archived?: number archived?: number
} }
@@ -1740,6 +1741,7 @@ export class Session2 extends HeyApiClient {
{ in: "query", key: "directory" }, { in: "query", key: "directory" },
{ in: "query", key: "workspace" }, { in: "query", key: "workspace" },
{ in: "body", key: "title" }, { in: "body", key: "title" },
{ in: "body", key: "permission" },
{ in: "body", key: "time" }, { in: "body", key: "time" },
], ],
}, },
+51 -50
View File
@@ -316,29 +316,6 @@ export type EventCommandExecuted = {
} }
} }
export type EventWorkspaceReady = {
type: "workspace.ready"
properties: {
name: string
}
}
export type EventWorkspaceFailed = {
type: "workspace.failed"
properties: {
message: string
}
}
export type EventWorkspaceStatus = {
type: "workspace.status"
properties: {
workspaceID: string
status: "connected" | "connecting" | "disconnected" | "error"
error?: string
}
}
export type QuestionOption = { export type QuestionOption = {
/** /**
* Display text (1-5 words, concise) * Display text (1-5 words, concise)
@@ -410,6 +387,29 @@ export type EventQuestionRejected = {
} }
} }
export type Todo = {
/**
* Brief description of the task
*/
content: string
/**
* Current status of the task: pending, in_progress, completed, cancelled
*/
status: string
/**
* Priority level of the task: high, medium, low
*/
priority: string
}
export type EventTodoUpdated = {
type: "todo.updated"
properties: {
sessionID: string
todos: Array<Todo>
}
}
export type SessionStatus = export type SessionStatus =
| { | {
type: "idle" type: "idle"
@@ -446,29 +446,6 @@ export type EventSessionCompacted = {
} }
} }
export type Todo = {
/**
* Brief description of the task
*/
content: string
/**
* Current status of the task: pending, in_progress, completed, cancelled
*/
status: string
/**
* Priority level of the task: high, medium, low
*/
priority: string
}
export type EventTodoUpdated = {
type: "todo.updated"
properties: {
sessionID: string
todos: Array<Todo>
}
}
export type EventWorktreeReady = { export type EventWorktreeReady = {
type: "worktree.ready" type: "worktree.ready"
properties: { properties: {
@@ -523,6 +500,29 @@ export type EventPtyDeleted = {
} }
} }
export type EventWorkspaceReady = {
type: "workspace.ready"
properties: {
name: string
}
}
export type EventWorkspaceFailed = {
type: "workspace.failed"
properties: {
message: string
}
}
export type EventWorkspaceStatus = {
type: "workspace.status"
properties: {
workspaceID: string
status: "connected" | "connecting" | "disconnected" | "error"
error?: string
}
}
export type OutputFormatText = { export type OutputFormatText = {
type: "text" type: "text"
} }
@@ -995,22 +995,22 @@ export type Event =
| EventMcpToolsChanged | EventMcpToolsChanged
| EventMcpBrowserOpenFailed | EventMcpBrowserOpenFailed
| EventCommandExecuted | EventCommandExecuted
| EventWorkspaceReady
| EventWorkspaceFailed
| EventWorkspaceStatus
| EventQuestionAsked | EventQuestionAsked
| EventQuestionReplied | EventQuestionReplied
| EventQuestionRejected | EventQuestionRejected
| EventTodoUpdated
| EventSessionStatus | EventSessionStatus
| EventSessionIdle | EventSessionIdle
| EventSessionCompacted | EventSessionCompacted
| EventTodoUpdated
| EventWorktreeReady | EventWorktreeReady
| EventWorktreeFailed | EventWorktreeFailed
| EventPtyCreated | EventPtyCreated
| EventPtyUpdated | EventPtyUpdated
| EventPtyExited | EventPtyExited
| EventPtyDeleted | EventPtyDeleted
| EventWorkspaceReady
| EventWorkspaceFailed
| EventWorkspaceStatus
| EventMessageUpdated | EventMessageUpdated
| EventMessageRemoved | EventMessageRemoved
| EventMessagePartUpdated | EventMessagePartUpdated
@@ -3266,6 +3266,7 @@ export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses]
export type SessionUpdateData = { export type SessionUpdateData = {
body?: { body?: {
title?: string title?: string
permission?: PermissionRuleset
time?: { time?: {
archived?: number archived?: number
} }
+124 -121
View File
@@ -2550,6 +2550,9 @@
"title": { "title": {
"type": "string" "type": "string"
}, },
"permission": {
"$ref": "#/components/schemas/PermissionRuleset"
},
"time": { "time": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -7986,71 +7989,6 @@
}, },
"required": ["type", "properties"] "required": ["type", "properties"]
}, },
"Event.workspace.ready": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "workspace.ready"
},
"properties": {
"type": "object",
"properties": {
"name": {
"type": "string"
}
},
"required": ["name"]
}
},
"required": ["type", "properties"]
},
"Event.workspace.failed": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "workspace.failed"
},
"properties": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
},
"required": ["message"]
}
},
"required": ["type", "properties"]
},
"Event.workspace.status": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "workspace.status"
},
"properties": {
"type": "object",
"properties": {
"workspaceID": {
"type": "string",
"pattern": "^wrk.*"
},
"status": {
"type": "string",
"enum": ["connected", "connecting", "disconnected", "error"]
},
"error": {
"type": "string"
}
},
"required": ["workspaceID", "status"]
}
},
"required": ["type", "properties"]
},
"QuestionOption": { "QuestionOption": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -8201,6 +8139,50 @@
}, },
"required": ["type", "properties"] "required": ["type", "properties"]
}, },
"Todo": {
"type": "object",
"properties": {
"content": {
"description": "Brief description of the task",
"type": "string"
},
"status": {
"description": "Current status of the task: pending, in_progress, completed, cancelled",
"type": "string"
},
"priority": {
"description": "Priority level of the task: high, medium, low",
"type": "string"
}
},
"required": ["content", "status", "priority"]
},
"Event.todo.updated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "todo.updated"
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"todos": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Todo"
}
}
},
"required": ["sessionID", "todos"]
}
},
"required": ["type", "properties"]
},
"SessionStatus": { "SessionStatus": {
"anyOf": [ "anyOf": [
{ {
@@ -8307,50 +8289,6 @@
}, },
"required": ["type", "properties"] "required": ["type", "properties"]
}, },
"Todo": {
"type": "object",
"properties": {
"content": {
"description": "Brief description of the task",
"type": "string"
},
"status": {
"description": "Current status of the task: pending, in_progress, completed, cancelled",
"type": "string"
},
"priority": {
"description": "Priority level of the task: high, medium, low",
"type": "string"
}
},
"required": ["content", "status", "priority"]
},
"Event.todo.updated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "todo.updated"
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"todos": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Todo"
}
}
},
"required": ["sessionID", "todos"]
}
},
"required": ["type", "properties"]
},
"Event.worktree.ready": { "Event.worktree.ready": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -8505,6 +8443,71 @@
}, },
"required": ["type", "properties"] "required": ["type", "properties"]
}, },
"Event.workspace.ready": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "workspace.ready"
},
"properties": {
"type": "object",
"properties": {
"name": {
"type": "string"
}
},
"required": ["name"]
}
},
"required": ["type", "properties"]
},
"Event.workspace.failed": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "workspace.failed"
},
"properties": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
},
"required": ["message"]
}
},
"required": ["type", "properties"]
},
"Event.workspace.status": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "workspace.status"
},
"properties": {
"type": "object",
"properties": {
"workspaceID": {
"type": "string",
"pattern": "^wrk.*"
},
"status": {
"type": "string",
"enum": ["connected", "connecting", "disconnected", "error"]
},
"error": {
"type": "string"
}
},
"required": ["workspaceID", "status"]
}
},
"required": ["type", "properties"]
},
"OutputFormatText": { "OutputFormatText": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -9937,15 +9940,6 @@
{ {
"$ref": "#/components/schemas/Event.command.executed" "$ref": "#/components/schemas/Event.command.executed"
}, },
{
"$ref": "#/components/schemas/Event.workspace.ready"
},
{
"$ref": "#/components/schemas/Event.workspace.failed"
},
{
"$ref": "#/components/schemas/Event.workspace.status"
},
{ {
"$ref": "#/components/schemas/Event.question.asked" "$ref": "#/components/schemas/Event.question.asked"
}, },
@@ -9955,6 +9949,9 @@
{ {
"$ref": "#/components/schemas/Event.question.rejected" "$ref": "#/components/schemas/Event.question.rejected"
}, },
{
"$ref": "#/components/schemas/Event.todo.updated"
},
{ {
"$ref": "#/components/schemas/Event.session.status" "$ref": "#/components/schemas/Event.session.status"
}, },
@@ -9964,9 +9961,6 @@
{ {
"$ref": "#/components/schemas/Event.session.compacted" "$ref": "#/components/schemas/Event.session.compacted"
}, },
{
"$ref": "#/components/schemas/Event.todo.updated"
},
{ {
"$ref": "#/components/schemas/Event.worktree.ready" "$ref": "#/components/schemas/Event.worktree.ready"
}, },
@@ -9985,6 +9979,15 @@
{ {
"$ref": "#/components/schemas/Event.pty.deleted" "$ref": "#/components/schemas/Event.pty.deleted"
}, },
{
"$ref": "#/components/schemas/Event.workspace.ready"
},
{
"$ref": "#/components/schemas/Event.workspace.failed"
},
{
"$ref": "#/components/schemas/Event.workspace.status"
},
{ {
"$ref": "#/components/schemas/Event.message.updated" "$ref": "#/components/schemas/Event.message.updated"
}, },
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
set -euo pipefail
# opencode Korean IME Fix Installer
# https://github.com/anomalyco/opencode/issues/14371
#
# Patches opencode to prevent Korean (and other CJK) IME last character
# truncation when pressing Enter in Kitty and other terminals.
#
# Usage:
# curl -fsSL https://raw.githubusercontent.com/claudianus/opencode/fix-zhipuai-coding-plan-thinking/patches/install-korean-ime-fix.sh | bash
# # or from a cloned repo:
# ./patches/install-korean-ime-fix.sh
RED='\033[0;31m'
GREEN='\033[0;32m'
ORANGE='\033[38;5;214m'
MUTED='\033[0;2m'
NC='\033[0m'
OPENCODE_DIR="${OPENCODE_DIR:-$HOME/.opencode}"
OPENCODE_SRC="${OPENCODE_SRC:-$HOME/.opencode-src}"
FORK_REPO="${FORK_REPO:-https://github.com/claudianus/opencode.git}"
FORK_BRANCH="${FORK_BRANCH:-fix-zhipuai-coding-plan-thinking}"
info() { echo -e "${MUTED}$*${NC}"; }
warn() { echo -e "${ORANGE}$*${NC}"; }
err() { echo -e "${RED}$*${NC}" >&2; }
ok() { echo -e "${GREEN}$*${NC}"; }
need() {
if ! command -v "$1" >/dev/null 2>&1; then
err "Error: $1 is required but not installed."
exit 1
fi
}
need git
need bun
# ── 1. Clone or update fork ────────────────────────────────────────────
if [ -d "$OPENCODE_SRC/.git" ]; then
info "Updating existing source at $OPENCODE_SRC ..."
git -C "$OPENCODE_SRC" fetch origin "$FORK_BRANCH"
git -C "$OPENCODE_SRC" checkout "$FORK_BRANCH"
git -C "$OPENCODE_SRC" reset --hard "origin/$FORK_BRANCH"
else
info "Cloning fork (shallow) to $OPENCODE_SRC ..."
git clone --depth 1 --branch "$FORK_BRANCH" "$FORK_REPO" "$OPENCODE_SRC"
fi
# ── 2. Verify the IME fix is present in source ────────────────────────
PROMPT_FILE="$OPENCODE_SRC/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx"
if [ ! -f "$PROMPT_FILE" ]; then
err "Prompt file not found: $PROMPT_FILE"
exit 1
fi
if grep -q "setTimeout(() => setTimeout" "$PROMPT_FILE"; then
ok "IME fix already present in source."
else
warn "IME fix not found. Applying patch ..."
# Apply the fix: replace onSubmit={submit} with double-deferred version
sed -i 's|onSubmit={submit}|onSubmit={() => {\n // IME: double-defer so the last composed character (e.g. Korean\n // hangul) is flushed to plainText before we read it for submission.\n setTimeout(() => setTimeout(() => submit(), 0), 0)\n }}|' "$PROMPT_FILE"
if grep -q "setTimeout(() => setTimeout" "$PROMPT_FILE"; then
ok "Patch applied."
else
err "Failed to apply patch. The source may have changed."
exit 1
fi
fi
# ── 3. Install dependencies ────────────────────────────────────────────
info "Installing dependencies (this may take a minute) ..."
cd "$OPENCODE_SRC"
bun install --frozen-lockfile 2>/dev/null || bun install
# ── 4. Build (current platform only) ──────────────────────────────────
info "Building opencode for current platform ..."
cd "$OPENCODE_SRC/packages/opencode"
bun run build --single
# ── 5. Install binary ──────────────────────────────────────────────────
mkdir -p "$OPENCODE_DIR/bin"
PLATFORM=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
[ "$ARCH" = "aarch64" ] && ARCH="arm64"
[ "$ARCH" = "x86_64" ] && ARCH="x64"
[ "$PLATFORM" = "darwin" ] && true
[ "$PLATFORM" = "linux" ] && true
BUILT_BINARY="$OPENCODE_SRC/packages/opencode/dist/opencode-${PLATFORM}-${ARCH}/bin/opencode"
if [ ! -f "$BUILT_BINARY" ]; then
BUILT_BINARY=$(find "$OPENCODE_SRC/packages/opencode/dist" -name "opencode" -type f -executable 2>/dev/null | head -1)
fi
if [ -f "$BUILT_BINARY" ]; then
if [ -f "$OPENCODE_DIR/bin/opencode" ]; then
cp "$OPENCODE_DIR/bin/opencode" "$OPENCODE_DIR/bin/opencode.bak.$(date +%Y%m%d%H%M%S)"
fi
cp "$BUILT_BINARY" "$OPENCODE_DIR/bin/opencode"
chmod +x "$OPENCODE_DIR/bin/opencode"
ok "Installed to $OPENCODE_DIR/bin/opencode"
else
err "Build failed - binary not found in dist/"
info "Try running manually:"
echo " cd $OPENCODE_SRC/packages/opencode && bun run build --single"
exit 1
fi
echo ""
ok "Done! Korean IME fix is now active."
echo ""
info "To uninstall and revert to the official release:"
echo " curl -fsSL https://opencode.ai/install | bash"
echo ""
info "To update (re-pull and rebuild):"
echo " $0"