Compare commits

...

44 Commits

Author SHA1 Message Date
Kit Langton
457a4b26fe fix: dispose e2e tool warmup runtime
Warm the tool registry through AppRuntime in seed-e2e and dispose that runtime after seeding so scoped tool init work does not keep the process alive and time out app e2e jobs.
2026-04-13 11:10:49 -04:00
Brendan Allan
94f71f59a3 core: make InstanceBootstrap into an effect (#22274)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-04-13 10:16:40 -04:00
Kit Langton
3eb6508a64 refactor: share TUI terminal background detection (#22297) 2026-04-13 10:05:37 -04:00
Kit Langton
6fdb8ab90d refactor(file): add ripgrep search service (#22295) 2026-04-13 10:04:32 -04:00
Kit Langton
321bf1f8e1 refactor: finish small effect service adoption cleanups (#22094) 2026-04-13 09:17:13 -04:00
Brendan Allan
62bd023086 app: replace parsePatchFiles with parseDiffFromFile (#22270) 2026-04-13 17:19:14 +08:00
Brendan Allan
cb1a50055c fix(electron): wait until ready before showing the main window (#22262) 2026-04-13 15:17:09 +08:00
opencode-agent[bot]
65e3348232 chore: update nix node_modules hashes 2026-04-13 06:02:50 +00:00
Brendan Allan
a6b9f0dac1 app: align workspace load more button (#22251) 2026-04-13 13:58:35 +08:00
Brendan Allan
34f5bdbc99 app: fix scroll to bottom light mode style (#22250) 2026-04-13 13:55:33 +08:00
Aiden Cline
0b4fe14b0a fix: forgot to put alibaba case in last commit (#22249) 2026-04-13 00:39:12 -05:00
Aiden Cline
7230cd2683 feat: add alibaba pkg and cache support (#22248) 2026-04-13 00:08:07 -05:00
Aiden Cline
a915fe74be tweak: adjust session getUsage function to use more up to date LanguageModelUsage instead of LanguageModelV2Usage (#22224) 2026-04-12 21:39:06 -05:00
Brendan Allan
26d35583c5 sdk: throw error if response has text/html content type (#21289) 2026-04-13 09:39:53 +08:00
Goni Zahavy
ae17b416b8 fix(cli): auth login now asks for api key in handlePluginAuth (#21641)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-12 20:37:57 -05:00
Aiden Cline
8ffadde85c chore: rm git ignored files (#22200) 2026-04-12 15:52:55 -05:00
Dax Raad
3c0ad70653 ci: enable beta branch releases with auto-update support 2026-04-12 14:40:24 -04:00
Dax
264418c0cd fix(snapshot): complete gitignore respect for previously tracked files (#22172) 2026-04-12 14:05:46 -04:00
shafdev
fa2c69f09c fix(opencode): remove spurious scripts and randomField from package.json (#22160) 2026-04-12 13:49:24 -04:00
Dax
113304a058 fix(snapshot): respect gitignore for previously tracked files (#22171) 2026-04-12 13:41:50 -04:00
Dax Raad
8c4d49c2bc ci: enable signed Windows builds on beta branch
Allows beta releases to include properly signed Windows CLI executables, ensuring consistent security verification across all release channels.
2026-04-12 13:16:38 -04:00
Dax Raad
2aa6110c6e ignore: exploration 2026-04-12 13:14:46 -04: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
113 changed files with 4327 additions and 64235 deletions

1
.github/VOUCHED.td vendored
View File

@@ -26,6 +26,7 @@ kommander
r44vc0rp
rekram1-node
-robinmordasiewicz
simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr
-toastythebot

View File

@@ -114,7 +114,7 @@ jobs:
- build-cli
- version
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
if: github.repository == 'anomalyco/opencode'
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -213,7 +213,6 @@ jobs:
needs:
- build-cli
- version
if: github.ref_name != 'beta'
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -390,7 +389,7 @@ jobs:
needs:
- build-cli
- version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
if: github.repository == 'anomalyco/opencode'
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -591,13 +590,12 @@ jobs:
path: packages/opencode/dist
- uses: actions/download-artifact@v4
if: github.ref_name != 'beta'
with:
name: opencode-cli-signed-windows
path: packages/opencode/dist
- uses: actions/download-artifact@v4
if: needs.version.outputs.release && github.ref_name != 'beta'
if: needs.version.outputs.release
with:
pattern: latest-yml-*
path: /tmp/latest-yml

View File

@@ -319,7 +319,8 @@
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/amazon-bedrock": "4.0.83",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.93",
"@ai-sdk/anthropic": "3.0.67",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
@@ -331,7 +332,7 @@
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@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/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23",
@@ -341,7 +342,6 @@
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0",
@@ -357,7 +357,7 @@
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "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/solid": "0.1.97",
"@parcel/watcher": "2.5.1",
@@ -663,7 +663,7 @@
"@types/node": "22.13.9",
"@types/semver": "7.7.1",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"ai": "6.0.149",
"ai": "6.0.158",
"cross-spawn": "7.0.6",
"diff": "8.0.2",
"dompurify": "3.3.1",
@@ -708,7 +708,9 @@
"@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/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
"@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=="],
@@ -1162,8 +1164,6 @@
"@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=="],
"@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 +1540,7 @@
"@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=="],
@@ -2386,7 +2386,7 @@
"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=="],
@@ -5006,7 +5006,13 @@
"@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/alibaba/@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=="],
"@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=="],
@@ -5282,10 +5288,6 @@
"@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=="],
"@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 +5556,9 @@
"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=="],
@@ -5770,6 +5774,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/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/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
@@ -5952,8 +5958,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=="],
"@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/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
@@ -6450,6 +6454,8 @@
"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=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -6822,6 +6828,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=="],
"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=="],
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-gFbo3B6TFAmin2marXlwUyfchTX6ogsaUFEzBIl4zaI=",
"aarch64-linux": "sha256-HUKL7zBVtb1KPaoAgfSfAzjDoAPRUe2WNFHDrsoqEF8=",
"aarch64-darwin": "sha256-qWPRkuVA3nDEEaVZ0Ex4sYsFFarSRJSyOn+KJm1D3U0=",
"x86_64-darwin": "sha256-FxhOYMXkxjn/9xQPeVX/gfQT/KjHT4wIBqzVDZuYlos="
"x86_64-linux": "sha256-g29OM3dy+sZ3ioTs8zjQOK1N+KnNr9ptP9xtdPcdr64=",
"aarch64-linux": "sha256-Iu91KwDcV5omkf4Ngny1aYpyCkPLjuoWOVUDOJUhW1k=",
"aarch64-darwin": "sha256-bk3G6m+Yo60Ea3Kyglc37QZf5Vm7MLMFcxemjc7HnL0=",
"x86_64-darwin": "sha256-y3hooQw13Z3Cu0KFfXYdpkTEeKTyuKd+a/jsXHQLdqA="
}
}

View File

@@ -48,7 +48,7 @@
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.46",
"ai": "6.0.149",
"ai": "6.0.158",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
"hono-openapi": "1.1.2",

View File

@@ -274,7 +274,7 @@ const WorkspaceSessionList = (props: {
<div class="relative w-full py-1">
<Button
variant="ghost"
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-2 pr-10"
size="large"
onClick={(e: MouseEvent) => {
props.loadMore()

View File

@@ -642,10 +642,10 @@ export function MessageTimeline(props: {
onClick={props.onResumeScroll}
>
<div
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-[var(--gray-dark-7)] bg-[color-mix(in_srgb,var(--gray-dark-3)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--gray-dark-8)] [--icon-base:var(--gray-dark-10)] group-hover:[--icon-base:var(--gray-dark-11)]"
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-border-weaker-base bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--border-weak-base)] group-hover:[--icon-base:var(--icon-hover)]"
style={{
"box-shadow":
"0 51px 60px 0 rgba(0,0,0,0.13), 0 15.375px 18.088px 0 rgba(0,0,0,0.19), 0 6.386px 7.513px 0 rgba(0,0,0,0.25), 0 2.31px 2.717px 0 rgba(0,0,0,0.38)",
"0 51px 60px 0 rgba(0,0,0,0.10), 0 15px 18px 0 rgba(0,0,0,0.12), 0 6.386px 7.513px 0 rgba(0,0,0,0.12), 0 2.31px 2.717px 0 rgba(0,0,0,0.20)",
}}
>
<Icon name="arrow-down-to-line" size="small" />

View File

@@ -66,7 +66,7 @@ export function createMainWindow(globals: Globals) {
y: state.y,
width: state.width,
height: state.height,
show: true,
show: false,
title: "OpenCode",
icon: iconPath(),
backgroundColor,
@@ -94,6 +94,10 @@ export function createMainWindow(globals: Globals) {
wireZoom(win)
injectGlobals(win, globals)
win.once("ready-to-show", () => {
win.show()
})
return win
}

View File

@@ -13,7 +13,7 @@
Use these rules when writing or migrating Effect code.
See `specs/effect-migration.md` for the compact pattern reference and examples.
See `specs/effect/migration.md` for the compact pattern reference and examples.
## Core
@@ -51,7 +51,7 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
## Effect.cached for deduplication
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect-migration.md` for the full pattern.
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect/migration.md` for the full pattern.
## Instance.bind — ALS for native callbacks

View File

@@ -14,18 +14,11 @@
"fix-node-pty": "bun run script/fix-node-pty.ts",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"dev": "bun run --conditions=browser ./src/index.ts",
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
"clean": "echo 'Cleaning up...' && rm -rf node_modules dist",
"lint": "echo 'Running lint checks...' && bun test --coverage",
"format": "echo 'Formatting code...' && bun run --prettier --write src/**/*.ts",
"docs": "echo 'Generating documentation...' && find src -name '*.ts' -exec echo 'Processing: {}' \\;",
"deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'",
"db": "bun drizzle-kit"
},
"bin": {
"opencode": "./bin/opencode"
},
"randomField": "this-is-a-random-value-12345",
"exports": {
"./*": "./src/*.ts"
},
@@ -39,6 +32,11 @@
"bun": "./src/pty/pty.bun.ts",
"node": "./src/pty/pty.node.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": {
@@ -78,7 +76,8 @@
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/amazon-bedrock": "4.0.83",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.93",
"@ai-sdk/anthropic": "3.0.67",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
@@ -90,7 +89,7 @@
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@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/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23",
@@ -100,7 +99,6 @@
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0",
@@ -116,7 +114,7 @@
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "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/solid": "0.1.97",
"@parcel/watcher": "2.5.1",

View File

@@ -1,3 +1,5 @@
import { AppRuntime } from "@/effect/app-runtime"
const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd()
const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session"
const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e"
@@ -20,10 +22,10 @@ const seed = async () => {
try {
await Instance.provide({
directory: dir,
init: InstanceBootstrap,
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: async () => {
await Config.waitForDependencies()
await ToolRegistry.ids()
await AppRuntime.runPromise(ToolRegistry.Service.use((svc) => svc.ids()))
const session = await Session.create({ title })
const messageID = MessageID.ascending()
@@ -54,6 +56,7 @@ const seed = async () => {
})
} finally {
await Instance.disposeAll().catch(() => {})
await AppRuntime.dispose().catch(() => {})
}
}

View File

@@ -0,0 +1,137 @@
# HttpApi migration
Practical notes for an eventual migration of `packages/opencode` server routes from the current Hono handlers to Effect `HttpApi`, either as a full replacement or as a parallel surface.
## Goal
Use Effect `HttpApi` where it gives us a better typed contract for:
- route definition
- request decoding and validation
- typed success and error responses
- OpenAPI generation
- handler composition inside Effect
This should be treated as a later-stage HTTP boundary migration, not a prerequisite for ongoing service, route-handler, or schema work.
## Core model
`HttpApi` is definition-first.
- `HttpApi` is the root API
- `HttpApiGroup` groups related endpoints
- `HttpApiEndpoint` defines a single route and its request / response schemas
- handlers are implemented separately from the contract
This is a better fit once route inputs and outputs are already moving toward Effect Schema-first models.
## Why it is relevant here
The current route-effectification work is already pushing handlers toward:
- one `AppRuntime.runPromise(Effect.gen(...))` body
- yielding services from context
- using typed Effect errors instead of Promise wrappers
That work is a good prerequisite for `HttpApi`. Once the handler body is already a composed Effect, the remaining migration is mostly about replacing the Hono route declaration and validator layer.
## What HttpApi gives us
### Contracts
Request params, query, payload, success payloads, and typed error payloads are declared in one place using Effect Schema.
### Validation and decoding
Incoming data is decoded through Effect Schema instead of hand-maintained Zod validators per route.
### OpenAPI
`HttpApi` can derive OpenAPI from the API definition, which overlaps with the current `describeRoute(...)` and `resolver(...)` pattern.
### Typed errors
`Schema.TaggedErrorClass` maps naturally to endpoint error contracts.
## Likely fit for opencode
Best fit first:
- JSON request / response endpoints
- route groups that already mostly delegate into services
- endpoints whose request and response models can be defined with Effect Schema
Harder / later fit:
- SSE endpoints
- websocket endpoints
- streaming handlers
- routes with heavy Hono-specific middleware assumptions
## Current blockers and gaps
### Schema split
Many route boundaries still use Zod-first validators. That does not block all experimentation, but full `HttpApi` adoption is easier after the domain and boundary types are more consistently Schema-first with `.zod` compatibility only where needed.
### Mixed handler styles
Many current `server/instance/*.ts` handlers still call async facades directly. Migrating those to composed `Effect.gen(...)` handlers is the low-risk step to do first.
### Non-JSON routes
The server currently includes SSE, websocket, and streaming-style endpoints. Those should not be the first `HttpApi` targets.
### Existing Hono integration
The current server composition, middleware, and docs flow are Hono-centered today. That suggests a parallel or incremental adoption plan is safer than a flag day rewrite.
## Recommended strategy
### 1. Finish the prerequisites first
- continue route-handler effectification in `server/instance/*.ts`
- continue schema migration toward Effect Schema-first DTOs and errors
- keep removing service facades
### 2. Start with one parallel group
Introduce one small `HttpApi` group for plain JSON endpoints only. Good initial candidates are the least stateful endpoints in:
- `server/instance/question.ts`
- `server/instance/provider.ts`
- `server/instance/permission.ts`
Avoid `session.ts`, SSE, websocket, and TUI-facing routes first.
### 3. Reuse existing services
Do not re-architect business logic during the HTTP migration. `HttpApi` handlers should call the same Effect services already used by the Hono handlers.
### 4. Run in parallel before replacing
Prefer mounting an experimental `HttpApi` surface alongside the existing Hono routes first. That lowers migration risk and lets us compare:
- handler ergonomics
- OpenAPI output
- auth and middleware integration
- test ergonomics
### 5. Migrate JSON route groups gradually
If the parallel slice works well, migrate additional JSON route groups one at a time. Leave streaming-style endpoints on Hono until there is a clear reason to move them.
## Proposed first steps
- [ ] add one small spike that defines an `HttpApi` group for a simple JSON route set
- [ ] use Effect Schema request / response types for that slice
- [ ] keep the underlying service calls identical to the current handlers
- [ ] compare generated OpenAPI against the current Hono/OpenAPI setup
- [ ] document how auth, instance lookup, and error mapping would compose in the new stack
- [ ] decide after the spike whether `HttpApi` should stay parallel, replace only some groups, or become the long-term default
## Rule of thumb
Do not start with the hardest route file.
If `HttpApi` is adopted here, it should arrive after the handler body is already Effect-native and after the relevant request / response models have moved to Effect Schema.

View File

@@ -178,7 +178,9 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
## Migration checklist
Fully migrated (single namespace, InstanceState where needed, flattened facade):
Service-shape migrated (single namespace, traced methods, `InstanceState` where needed).
This checklist is only about the service shape migration. Many of these services still keep `makeRuntime(...)` plus async facade exports; that facade-removal phase is tracked separately in [Destroying the facades](#destroying-the-facades).
- [x] `Account``account/index.ts`
- [x] `Agent``agent/agent.ts`
@@ -217,62 +219,20 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
- [x] `SessionSummary``session/summary.ts`
- [x] `SessionRevert``session/revert.ts`
- [x] `Instruction``session/instruction.ts`
- [x] `SystemPrompt``session/system.ts`
- [x] `Provider``provider/provider.ts`
- [x] `Storage``storage/storage.ts`
- [x] `ShareNext``share/share-next.ts`
Still open:
- [x] `SessionTodo``session/todo.ts`
- [ ] `SyncEvent``sync/index.ts`
- [ ] `Workspace``control-plane/workspace.ts`
## Tool interface → Effect
Still open at the service-shape level:
`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:
- [ ] `SyncEvent``sync/index.ts` (deferred pending sync with James)
- [ ] `Workspace``control-plane/workspace.ts` (deferred pending sync with James)
1. Migrate each tool body to return Effects
2. Keep `Tool.define()` inputs Effect-native
3. Update remaining callers to `yield*` tool initialization instead of `await`ing
## Tool migration
### Tool migration details
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.
- 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.
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.
- 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.
This keeps migrated tool tests aligned with the production service graph today, and makes the eventual `Tool.Info``Effect` cleanup mostly mechanical later.
Individual tools, ordered by value:
- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
- [ ] `bash.ts` — HIGH: shell orchestration, quoting, timeout handling, output capture
- [x] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
- [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
- [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
- [ ] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events
- [ ] `codesearch.ts` — MEDIUM: HTTP + SSE + manual timeout → HttpClient + Effect.timeout
- [ ] `webfetch.ts` — MEDIUM: fetch with UA retry, size limits → HttpClient
- [ ] `websearch.ts` — MEDIUM: MCP over HTTP → HttpClient
- [ ] `batch.ts` — MEDIUM: parallel execution, per-call error recovery → Effect.all
- [ ] `task.ts` — MEDIUM: task state management
- [ ] `ls.ts` — MEDIUM: bounded directory listing over ripgrep-backed traversal
- [ ] `multiedit.ts` — MEDIUM: sequential edit orchestration over `edit.ts`
- [ ] `glob.ts` — LOW: simple async generator
- [ ] `lsp.ts` — LOW: dispatch switch over LSP operations
- [ ] `question.ts` — LOW: prompt wrapper
- [ ] `skill.ts` — LOW: skill tool adapter
- [ ] `todo.ts` — LOW: todo persistence wrapper
- [ ] `invalid.ts` — LOW: invalid-tool fallback
- [ ] `plan.ts` — LOW: plan file operations
Tool-specific migration guidance and checklist live in `tools.md`.
## Effect service adoption in already-migrated code
@@ -280,27 +240,19 @@ Some already-effectified areas still use raw `Filesystem.*` or `Process.spawn` i
### `Filesystem.*``AppFileSystem.Service` (yield in layer)
- [ ] `file/index.ts` — 1 remaining `Filesystem.readText()` call in untracked diff handling
- [ ] `config/config.ts` 5 remaining `Filesystem.*` calls in `installDependencies()`
- [ ] `provider/provider.ts` — 1 remaining `Filesystem.readJson()` call for recent model state
- [x] `config/config.ts``installDependencies()` now uses `AppFileSystem`
- [x] `provider/provider.ts` — recent model state now reads via `AppFileSystem.Service`
### `Process.spawn``ChildProcessSpawner` (yield in layer)
- [ ] `format/formatter.ts`2 remaining `Process.spawn()` checks (`air`, `uv`)
- [x] `format/formatter.ts`direct `Process.spawn()` checks removed (`air`, `uv`)
- [ ] `lsp/server.ts` — multiple `Process.spawn()` installs/download helpers
## Filesystem consolidation
`util/filesystem.ts` (raw fs wrapper) is currently imported by **34 files**. The effectified `AppFileSystem` service (`filesystem/index.ts`) is currently imported by **15 files**. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` — this happens naturally during each migration, not as a separate effort.
`util/filesystem.ts` is still used widely across `src/`, and raw `fs` / `fs/promises` imports still exist in multiple tooling and infrastructure files. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` where possible — this should happen naturally during each migration, not as a separate sweep.
Similarly, **21 files** still import raw `fs` or `fs/promises` directly. These should migrate to `AppFileSystem` or `Filesystem.*` as they're touched.
Current raw fs users that will convert during tool migration:
- `tool/read.ts` — fs.createReadStream, readline
- `tool/apply_patch.ts` — fs/promises
- `file/ripgrep.ts` — fs/promises
- `patch/index.ts` — fs, fs/promises
Tool-specific filesystem cleanup notes live in `tools.md`.
## Primitives & utilities
@@ -311,7 +263,9 @@ Current raw fs users that will convert during tool migration:
## Destroying the facades
Every service currently exports async facade functions at the bottom of its namespace — `export async function read(...) { return runPromise(...) }` — backed by a per-service `makeRuntime`. These exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
This phase is still broadly open. As of 2026-04-11 there are still 31 `makeRuntime(...)` call sites under `src/`, and many service namespaces still export async facade helpers like `export async function read(...) { return runPromise(...) }`.
These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
### Process
@@ -340,3 +294,14 @@ 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.
- `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.
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/instance/session.ts` converted; facade removed.
- `Account` — migrated 2026-04-11. Callers in `server/instance/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/instance/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-handler migration guidance and checklist live in `routes.md`.

View File

@@ -0,0 +1,66 @@
# Route handler effectification
Practical reference for converting server route handlers in `packages/opencode` to a single `AppRuntime.runPromise(Effect.gen(...))` body.
## Goal
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)
}
```
## Rules
- Wrap the whole handler body in one `AppRuntime.runPromise(Effect.gen(...))` call when the handler is service-heavy.
- Yield services from context instead of calling async facades repeatedly.
- When independent service calls can run in parallel, use `Effect.all(..., { concurrency: "unbounded" })`.
- Prefer one composed Effect body over multiple separate `runPromise(...)` calls in the same handler.
## Current route files
Current instance route files live under `src/server/instance`, not `server/routes`.
The main migration targets are:
- [ ] `server/instance/session.ts` — heaviest; still has many direct facade calls for Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, Agent, Bus
- [ ] `server/instance/global.ts` — still has direct facade calls for Config and instance lifecycle actions
- [ ] `server/instance/provider.ts` — still has direct facade calls for Config and Provider
- [ ] `server/instance/question.ts` — partially converted; still worth tracking here until it consistently uses the composed style
- [ ] `server/instance/pty.ts` — still calls Pty facades directly
- [ ] `server/instance/experimental.ts` — mixed state; some handlers are already composed, others still use facades
Additional route files that still participate in the migration:
- [ ] `server/instance/index.ts` — Vcs, Agent, Skill, LSP, Format
- [ ] `server/instance/file.ts` — Ripgrep, File, LSP
- [ ] `server/instance/mcp.ts` — MCP facade-heavy
- [ ] `server/instance/permission.ts` — Permission
- [ ] `server/instance/workspace.ts` — Workspace
- [ ] `server/instance/tui.ts` — Bus and Session
- [ ] `server/instance/middleware.ts` — Session and Workspace lookups
## Notes
- Some handlers already use `AppRuntime.runPromise(Effect.gen(...))` in isolated places. Keep pushing those files toward one consistent style.
- Route conversion is closely tied to facade removal. As services lose `makeRuntime`-backed async exports, route handlers should switch to yielding the service directly.

View File

@@ -0,0 +1,99 @@
# Schema migration
Practical reference for migrating data types in `packages/opencode` from Zod-first definitions to Effect Schema with Zod compatibility shims.
## Goal
Use Effect Schema as the source of truth for domain models, IDs, inputs, outputs, and typed errors.
Keep Zod available at existing HTTP, tool, and compatibility boundaries by exposing a `.zod` field derived from the Effect schema.
## Preferred shapes
### Data objects
Use `Schema.Class` for structured data.
```ts
export class Info extends Schema.Class<Info>("Foo.Info")({
id: FooID,
name: Schema.String,
enabled: Schema.Boolean,
}) {
static readonly zod = zod(Info)
}
```
If the class cannot reference itself cleanly during initialization, use the existing two-step pattern:
```ts
const _Info = Schema.Struct({
id: FooID,
name: Schema.String,
})
export const Info = Object.assign(_Info, {
zod: zod(_Info),
})
```
### Errors
Use `Schema.TaggedErrorClass` for domain errors.
```ts
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("FooNotFoundError", {
id: FooID,
}) {}
```
### IDs and branded leaf types
Keep branded/schema-backed IDs as Effect schemas and expose `static readonly zod` for compatibility when callers still expect Zod.
## Compatibility rule
During migration, route validators, tool parameters, and any existing Zod-based boundary should consume the derived `.zod` schema instead of maintaining a second hand-written Zod schema.
The default should be:
- Effect Schema owns the type
- `.zod` exists only as a compatibility surface
- new domain models should not start Zod-first unless there is a concrete boundary-specific need
## When Zod can stay
It is fine to keep a Zod-native schema temporarily when:
- the type is only used at an HTTP or tool boundary
- the validator depends on Zod-only transforms or behavior not yet covered by `zod()`
- the migration would force unrelated churn across a large call graph
When this happens, prefer leaving a short note or TODO rather than silently creating a parallel schema source of truth.
## Ordering
Migrate in this order:
1. Shared leaf models and `schema.ts` files
2. Exported `Info`, `Input`, `Output`, and DTO types
3. Tagged domain errors
4. Service-local internal models
5. Route and tool boundary validators that can switch to `.zod`
This keeps shared types canonical first and makes boundary updates mostly mechanical.
## Checklist
- [ ] Shared `schema.ts` leaf models are Effect Schema-first
- [ ] Exported `Info` / `Input` / `Output` types use `Schema.Class` where appropriate
- [ ] Domain errors use `Schema.TaggedErrorClass`
- [ ] Migrated types expose `.zod` for back compatibility
- [ ] Route and tool validators consume derived `.zod` instead of duplicate Zod definitions
- [ ] New domain models default to Effect Schema first
## Notes
- Use `@/util/effect-zod` for all Schema -> Zod conversion.
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and Effect definitions for the same domain type.
- Keep the migration incremental. Converting the domain model first is more valuable than converting every boundary in the same change.

View File

@@ -0,0 +1,96 @@
# Tool migration
Practical reference for the current tool-migration state in `packages/opencode`.
## Status
`Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch, and the built-in tool surface is now largely on the target shape.
The current exported tools in `src/tool` all use `Tool.define(...)` with Effect-based initialization, and nearly all of them already build their tool body with `Effect.gen(...)` and `Effect.fn(...)`.
So the remaining work is no longer "convert tools to Effect at all". The remaining work is mostly:
1. remove Promise and raw platform bridges inside individual tool bodies
2. swap tool internals to Effect-native services like `AppFileSystem`, `HttpClient`, and `ChildProcessSpawner`
3. keep tests and callers aligned with `yield* info.init()` and real service graphs
## Current shape
`Tool.define(...)` is already the Effect-native helper here.
- `init` is an `Effect`
- `info.init()` returns an `Effect`
- `execute(...)` returns an `Effect`
That means a tool does not need a separate `Tool.defineEffect(...)` helper to count as migrated. A tool is effectively migrated when its init and execute path stay Effect-native, even if some internals still bridge to Promise-based or raw APIs.
## Tests
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.
- 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.
This keeps tool tests aligned with the production service graph and makes follow-up cleanup mostly mechanical.
## Exported tools
These exported tool definitions already exist in `src/tool` and are on the current Effect-native `Tool.define(...)` path:
- [x] `apply_patch.ts`
- [x] `bash.ts`
- [x] `codesearch.ts`
- [x] `edit.ts`
- [x] `glob.ts`
- [x] `grep.ts`
- [x] `invalid.ts`
- [x] `ls.ts`
- [x] `lsp.ts`
- [x] `multiedit.ts`
- [x] `plan.ts`
- [x] `question.ts`
- [x] `read.ts`
- [x] `skill.ts`
- [x] `task.ts`
- [x] `todo.ts`
- [x] `webfetch.ts`
- [x] `websearch.ts`
- [x] `write.ts`
Notes:
- `batch.ts` is no longer a current tool file and should not be tracked here.
- `truncate.ts` is an Effect service used by tools, not a tool definition itself.
- `mcp-exa.ts`, `external-directory.ts`, and `schema.ts` are support modules, not standalone tool definitions.
## Follow-up cleanup
Most exported tools are already on the intended Effect-native shape. The remaining cleanup is narrower than the old checklist implied.
Current spot cleanups worth tracking:
- [ ] `read.ts` — still bridges to Node stream / `readline` helpers and Promise-based binary detection
- [ ] `bash.ts` — already uses Effect child-process primitives; only keep tracking shell-specific platform bridges and parser/loading details as they come up
- [ ] `webfetch.ts` — already uses `HttpClient`; remaining work is limited to smaller boundary helpers like HTML text extraction
- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and `ls.ts`
- [ ] `patch/index.ts` — adjacent to tool migration; still has raw fs usage behind patch application
Notable items that are already effectively on the target path and do not need separate migration bullets right now:
- `apply_patch.ts`
- `grep.ts`
- `write.ts`
- `codesearch.ts`
- `websearch.ts`
- `ls.ts`
- `multiedit.ts`
- `edit.ts`
## Filesystem notes
Current raw fs users that still appear relevant here:
- `tool/read.ts``fs.createReadStream`, `readline`
- `file/ripgrep.ts``fs/promises`
- `patch/index.ts``fs`, `fs/promises`

View File

@@ -398,13 +398,11 @@ export namespace Agent {
}),
)
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Provider.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
),
export const defaultLayer = layer.pipe(
Layer.provide(Provider.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)

View File

@@ -1,10 +1,11 @@
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceBootstrap } from "../project/bootstrap"
import { Instance } from "../project/instance"
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
return Instance.provide({
directory,
init: InstanceBootstrap,
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: async () => {
try {
const result = await cb()

View File

@@ -1,4 +1,5 @@
import { EOL } from "os"
import { AppRuntime } from "../../../effect/app-runtime"
import { Ripgrep } from "../../../file/ripgrep"
import { Instance } from "../../../project/instance"
import { bootstrap } from "../../bootstrap"
@@ -76,12 +77,18 @@ const SearchCommand = cmd({
description: "Limit number of results",
}),
async handler(args) {
const results = await Ripgrep.search({
cwd: process.cwd(),
pattern: args.pattern,
glob: args.glob as string[] | undefined,
limit: args.limit,
await bootstrap(process.cwd(), async () => {
const results = await AppRuntime.runPromise(
Ripgrep.Service.use((svc) =>
svc.search({
cwd: Instance.directory,
pattern: args.pattern,
glob: args.glob as string[] | undefined,
limit: args.limit,
}),
),
)
process.stdout.write(JSON.stringify(results.items, null, 2) + EOL)
})
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
},
})

View File

@@ -148,6 +148,12 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
}
if (method.type === "api") {
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
if (method.authorize) {
const result = await method.authorize(inputs)
if (result.type === "failed") {
@@ -157,7 +163,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
const saveProvider = result.provider ?? provider
await Auth.set(saveProvider, {
type: "api",
key: result.key,
key: result.key ?? key,
})
prompts.log.success("Login successful")
}

View File

@@ -1,6 +1,7 @@
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { Selection } from "@tui/util/selection"
import { Terminal } from "@tui/util/terminal"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import {
@@ -60,66 +61,6 @@ import { TuiConfig } from "@/config/tui"
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
import { FormatError, FormatUnknownError } from "@/cli/error"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
if (!process.stdin.isTTY) return "dark"
return new Promise((resolve) => {
let timeout: NodeJS.Timeout
const cleanup = () => {
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
}
const handler = (data: Buffer) => {
const str = data.toString()
const match = str.match(/\x1b]11;([^\x07\x1b]+)/)
if (match) {
cleanup()
const color = match[1]
// Parse RGB values from color string
// Formats: rgb:RR/GG/BB or #RRGGBB or rgb(R,G,B)
let r = 0,
g = 0,
b = 0
if (color.startsWith("rgb:")) {
const parts = color.substring(4).split("/")
r = parseInt(parts[0], 16) >> 8 // Convert 16-bit to 8-bit
g = parseInt(parts[1], 16) >> 8 // Convert 16-bit to 8-bit
b = parseInt(parts[2], 16) >> 8 // Convert 16-bit to 8-bit
} else if (color.startsWith("#")) {
r = parseInt(color.substring(1, 3), 16)
g = parseInt(color.substring(3, 5), 16)
b = parseInt(color.substring(5, 7), 16)
} else if (color.startsWith("rgb(")) {
const parts = color.substring(4, color.length - 1).split(",")
r = parseInt(parts[0])
g = parseInt(parts[1])
b = parseInt(parts[2])
}
// Calculate luminance using relative luminance formula
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
// Determine if dark or light based on luminance threshold
resolve(luminance > 0.5 ? "light" : "dark")
}
}
process.stdin.setRawMode(true)
process.stdin.on("data", handler)
process.stdout.write("\x1b]11;?\x07")
timeout = setTimeout(() => {
cleanup()
resolve("dark")
}, 1000)
})
}
import type { EventSource } from "./context/sdk"
import { DialogVariant } from "./component/dialog-variant"
@@ -178,7 +119,7 @@ export function tui(input: {
const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput()
const mode = await getTerminalBackgroundColor()
const mode = await Terminal.getTerminalBackgroundColor()
// Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores
// the original console mode which re-enables ENABLE_PROCESSED_INPUT.

View File

@@ -589,6 +589,13 @@ export function Prompt(props: PromptProps) {
])
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 (autocomplete?.visible) return
if (!store.prompt.input) return
@@ -994,7 +1001,11 @@ export function Prompt(props: PromptProps) {
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) => {
if (props.disabled) {
event.preventDefault()

View File

@@ -137,12 +137,18 @@ export const TuiThreadCommand = cmd({
),
})
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 error = (e: unknown) => {
Log.Default.error(e)
Log.Default.error("process error", { error: errorMessage(e) })
}
const reload = () => {
client.call("reload", undefined).catch((err) => {

View File

@@ -2,6 +2,28 @@ import { RGBA } from "@opentui/core"
export namespace Terminal {
export type Colors = Awaited<ReturnType<typeof colors>>
function parse(color: string): RGBA | null {
if (color.startsWith("rgb:")) {
const parts = color.substring(4).split("/")
return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255)
}
if (color.startsWith("#")) {
return RGBA.fromHex(color)
}
if (color.startsWith("rgb(")) {
const parts = color.substring(4, color.length - 1).split(",")
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
}
return null
}
function mode(bg: RGBA | null): "dark" | "light" {
if (!bg) return "dark"
const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
return luminance > 0.5 ? "light" : "dark"
}
/**
* Query terminal colors including background, foreground, and palette (0-15).
* Uses OSC escape sequences to retrieve actual terminal color values.
@@ -31,46 +53,26 @@ export namespace Terminal {
clearTimeout(timeout)
}
const parseColor = (colorStr: string): RGBA | null => {
if (colorStr.startsWith("rgb:")) {
const parts = colorStr.substring(4).split("/")
return RGBA.fromInts(
parseInt(parts[0], 16) >> 8, // Convert 16-bit to 8-bit
parseInt(parts[1], 16) >> 8,
parseInt(parts[2], 16) >> 8,
255,
)
}
if (colorStr.startsWith("#")) {
return RGBA.fromHex(colorStr)
}
if (colorStr.startsWith("rgb(")) {
const parts = colorStr.substring(4, colorStr.length - 1).split(",")
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
}
return null
}
const handler = (data: Buffer) => {
const str = data.toString()
// Match OSC 11 (background color)
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
if (bgMatch) {
background = parseColor(bgMatch[1])
background = parse(bgMatch[1])
}
// Match OSC 10 (foreground color)
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
if (fgMatch) {
foreground = parseColor(fgMatch[1])
foreground = parse(fgMatch[1])
}
// Match OSC 4 (palette colors)
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
for (const match of paletteMatches) {
const index = parseInt(match[1])
const color = parseColor(match[2])
const color = parse(match[2])
if (color) paletteColors[index] = color
}
@@ -100,15 +102,36 @@ export namespace Terminal {
})
}
// Keep startup mode detection separate from `colors()`: the TUI boot path only
// needs OSC 11 and should resolve on the first background response instead of
// waiting on the full palette query used by system theme generation.
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
const result = await colors()
if (!result.background) return "dark"
if (!process.stdin.isTTY) return "dark"
const { r, g, b } = result.background
// Calculate luminance using relative luminance formula
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return new Promise((resolve) => {
let timeout: NodeJS.Timeout
// Determine if dark or light based on luminance threshold
return luminance > 0.5 ? "light" : "dark"
const cleanup = () => {
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
}
const handler = (data: Buffer) => {
const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/)
if (!match) return
cleanup()
resolve(mode(parse(match[1])))
}
process.stdin.setRawMode(true)
process.stdin.on("data", handler)
process.stdout.write("\x1b]11;?\x07")
timeout = setTimeout(() => {
cleanup()
resolve("dark")
}, 1000)
})
}
}

View File

@@ -7,10 +7,10 @@ import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { Flag } from "@/flag/flag"
import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime"
await Log.init({
print: process.argv.includes("--print-logs"),
@@ -74,7 +74,7 @@ export const rpc = {
async checkUpgrade(input: { directory: string }) {
await Instance.provide({
directory: input.directory,
init: InstanceBootstrap,
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: async () => {
await upgrade().catch(() => {})
},

View File

@@ -4,7 +4,6 @@ import { pathToFileURL } from "url"
import os from "os"
import { Process } from "../util/process"
import z from "zod"
import { ModelsDev } from "../provider/models"
import { mergeDeep, pipe, unique } from "remeda"
import { Global } from "../global"
import fsNode from "fs/promises"
@@ -23,21 +22,19 @@ import { Instance, type InstanceContext } from "../project/instance"
import { LSPServer } from "../lsp/server"
import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown"
import { constants, existsSync } from "fs"
import { existsSync } from "fs"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { Glob } from "../util/glob"
import { iife } from "@/util/iife"
import { Account } from "@/account"
import { isRecord } from "@/util/record"
import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Duration, Effect, Layer, Option, Context } from "effect"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
import { Flock } from "@/util/flock"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
import { Npm } from "@/npm"
@@ -141,53 +138,11 @@ export namespace Config {
}
export type InstallInput = {
signal?: AbortSignal
waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise<void>
}
export async function installDependencies(dir: string, input?: InstallInput) {
if (!(await isWritable(dir))) return
await using _ = await Flock.acquire(`config-install:${Filesystem.resolve(dir)}`, {
signal: input?.signal,
onWait: (tick) =>
input?.waitTick?.({
dir,
attempt: tick.attempt,
delay: tick.delay,
waited: tick.waited,
}),
})
input?.signal?.throwIfAborted()
const pkg = path.join(dir, "package.json")
const target = Installation.isLocal() ? "*" : Installation.VERSION
const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
dependencies: {},
}))
json.dependencies = {
...json.dependencies,
"@opencode-ai/plugin": target,
}
await Filesystem.writeJson(pkg, json)
const gitignore = path.join(dir, ".gitignore")
const ignore = await Filesystem.exists(gitignore)
if (!ignore) {
await Filesystem.write(
gitignore,
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
)
}
await Npm.install(dir)
}
async function isWritable(dir: string) {
try {
await fsNode.access(dir, constants.W_OK)
return true
} catch {
return false
}
type Package = {
dependencies?: Record<string, string>
}
function rel(item: string, patterns: string[]) {
@@ -1112,7 +1067,7 @@ export namespace Config {
type State = {
config: Info
directories: string[]
deps: Promise<void>[]
deps: Fiber.Fiber<void, never>[]
consoleState: ConsoleState
}
@@ -1120,6 +1075,7 @@ export namespace Config {
readonly get: () => Effect.Effect<Info>
readonly getGlobal: () => Effect.Effect<Info>
readonly getConsoleState: () => Effect.Effect<ConsoleState>
readonly installDependencies: (dir: string, input?: InstallInput) => Effect.Effect<void, AppFileSystem.Error>
readonly update: (config: Info) => Effect.Effect<void>
readonly updateGlobal: (config: Info) => Effect.Effect<Info>
readonly invalidate: (wait?: boolean) => Effect.Effect<void>
@@ -1321,6 +1277,74 @@ export namespace Config {
return yield* cachedGlobal
})
const install = Effect.fnUntraced(function* (dir: string) {
const pkg = path.join(dir, "package.json")
const gitignore = path.join(dir, ".gitignore")
const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json")
const target = Installation.isLocal() ? "*" : Installation.VERSION
const json = yield* fs.readJson(pkg).pipe(
Effect.catch(() => Effect.succeed({} satisfies Package)),
Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})),
)
const hasDep = json.dependencies?.["@opencode-ai/plugin"] === target
const hasIgnore = yield* fs.existsSafe(gitignore)
const hasPkg = yield* fs.existsSafe(plugin)
if (!hasDep) {
yield* fs.writeJson(pkg, {
...json,
dependencies: {
...json.dependencies,
"@opencode-ai/plugin": target,
},
})
}
if (!hasIgnore) {
yield* fs.writeFileString(
gitignore,
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
)
}
if (hasDep && hasIgnore && hasPkg) return
yield* Effect.promise(() => Npm.install(dir))
})
const installDependencies = Effect.fn("Config.installDependencies")(function* (
dir: string,
input?: InstallInput,
) {
if (
!(yield* fs.access(dir, { writable: true }).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
))
)
return
const key =
process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}`
yield* Effect.acquireUseRelease(
Effect.promise((signal) =>
Flock.acquire(key, {
signal,
onWait: (tick) =>
input?.waitTick?.({
dir,
attempt: tick.attempt,
delay: tick.delay,
waited: tick.waited,
}),
}),
),
() => install(dir),
(lease) => Effect.promise(() => lease.release()),
)
})
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
const auth = yield* authSvc.all().pipe(Effect.orDie)
@@ -1403,7 +1427,7 @@ export namespace Config {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}
const deps: Promise<void>[] = []
const deps: Fiber.Fiber<void, never>[] = []
for (const dir of unique(directories)) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
@@ -1417,12 +1441,18 @@ export namespace Config {
}
}
const dep = iife(async () => {
await installDependencies(dir)
})
void dep.catch((err) => {
log.warn("background dependency install failed", { dir, error: err })
})
const dep = yield* installDependencies(dir).pipe(
Effect.exit,
Effect.tap((exit) =>
Exit.isFailure(exit)
? Effect.sync(() => {
log.warn("background dependency install failed", { dir, error: String(exit.cause) })
})
: Effect.void,
),
Effect.asVoid,
Effect.forkScoped,
)
deps.push(dep)
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
@@ -1559,7 +1589,9 @@ export namespace Config {
})
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
yield* InstanceState.useEffect(state, (s) => Effect.promise(() => Promise.all(s.deps).then(() => undefined)))
yield* InstanceState.useEffect(state, (s) =>
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
)
})
const update = Effect.fn("Config.update")(function* (config: Info) {
@@ -1614,6 +1646,7 @@ export namespace Config {
get,
getGlobal,
getConsoleState,
installDependencies,
update,
updateGlobal,
invalidate,
@@ -1643,6 +1676,10 @@ export namespace Config {
return runPromise((svc) => svc.getConsoleState())
}
export async function installDependencies(dir: string, input?: InstallInput) {
return runPromise((svc) => svc.installDependencies(dir, input))
}
export async function update(config: Info) {
return runPromise((svc) => svc.update(config))
}

View File

@@ -49,7 +49,7 @@ import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
export const AppLayer = Layer.mergeAll(
Observability.layer,
// Observability.layer,
AppFileSystem.defaultLayer,
Bus.defaultLayer,
Auth.defaultLayer,
@@ -95,6 +95,6 @@ export const AppLayer = Layer.mergeAll(
Installation.defaultLayer,
ShareNext.defaultLayer,
SessionShare.defaultLayer,
)
).pipe(Layer.provide(Observability.layer))
export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })

View File

@@ -1,9 +1,27 @@
import { Layer, ManagedRuntime } from "effect"
import { memoMap } from "./run-service"
import { Plugin } from "@/plugin"
import { LSP } from "@/lsp"
import { FileWatcher } from "@/file/watcher"
import { Format } from "@/format"
import { ShareNext } from "@/share/share-next"
import { File } from "@/file"
import { Vcs } from "@/project/vcs"
import { Snapshot } from "@/snapshot"
import { Bus } from "@/bus"
import { Observability } from "./oltp"
export const BootstrapLayer = Layer.mergeAll(Format.defaultLayer, ShareNext.defaultLayer)
export const BootstrapLayer = Layer.mergeAll(
Plugin.defaultLayer,
ShareNext.defaultLayer,
Format.defaultLayer,
LSP.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
Vcs.defaultLayer,
Snapshot.defaultLayer,
Bus.defaultLayer,
).pipe(Layer.provide(Observability.layer))
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })

View File

@@ -96,6 +96,7 @@ export namespace Ripgrep {
export type Result = z.infer<typeof Result>
export type Match = z.infer<typeof Match>
export type Item = Match["data"]
export type Begin = z.infer<typeof Begin>
export type End = z.infer<typeof End>
export type Summary = z.infer<typeof Summary>
@@ -289,6 +290,13 @@ export namespace Ripgrep {
follow?: boolean
maxDepth?: number
}) => Stream.Stream<string, PlatformError>
readonly search: (input: {
cwd: string
pattern: string
glob?: string[]
limit?: number
follow?: boolean
}) => Effect.Effect<{ items: Item[]; partial: boolean }, PlatformError | Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
@@ -298,6 +306,32 @@ export namespace Ripgrep {
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const afs = yield* AppFileSystem.Service
const bin = Effect.fn("Ripgrep.path")(function* () {
return yield* Effect.promise(() => filepath())
})
const args = Effect.fn("Ripgrep.args")(function* (input: {
mode: "files" | "search"
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
limit?: number
pattern?: string
}) {
const out = [yield* bin(), input.mode === "search" ? "--json" : "--files", "--glob=!.git/*"]
if (input.follow) out.push("--follow")
if (input.hidden !== false) out.push("--hidden")
if (input.maxDepth !== undefined) out.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
for (const g of input.glob) {
out.push(`--glob=${g}`)
}
}
if (input.limit) out.push(`--max-count=${input.limit}`)
if (input.mode === "search") out.push("--no-messages")
if (input.pattern) out.push("--", input.pattern)
return out
})
const files = Effect.fn("Ripgrep.files")(function* (input: {
cwd: string
@@ -306,7 +340,7 @@ export namespace Ripgrep {
follow?: boolean
maxDepth?: number
}) {
const rgPath = yield* Effect.promise(() => filepath())
const rgPath = yield* bin()
const isDir = yield* afs.isDir(input.cwd)
if (!isDir) {
return yield* Effect.die(
@@ -318,23 +352,76 @@ export namespace Ripgrep {
)
}
const args = [rgPath, "--files", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
if (input.hidden !== false) args.push("--hidden")
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
for (const g of input.glob) {
args.push(`--glob=${g}`)
}
}
const cmd = yield* args({
mode: "files",
glob: input.glob,
hidden: input.hidden,
follow: input.follow,
maxDepth: input.maxDepth,
})
return spawner
.streamLines(ChildProcess.make(args[0], args.slice(1), { cwd: input.cwd }))
.streamLines(ChildProcess.make(cmd[0], cmd.slice(1), { cwd: input.cwd }))
.pipe(Stream.filter((line: string) => line.length > 0))
})
const search = Effect.fn("Ripgrep.search")(function* (input: {
cwd: string
pattern: string
glob?: string[]
limit?: number
follow?: boolean
}) {
return yield* Effect.scoped(
Effect.gen(function* () {
const cmd = yield* args({
mode: "search",
glob: input.glob,
follow: input.follow,
limit: input.limit,
pattern: input.pattern,
})
const handle = yield* spawner.spawn(
ChildProcess.make(cmd[0], cmd.slice(1), {
cwd: input.cwd,
stdin: "ignore",
}),
)
const [stdout, stderr, code] = yield* Effect.all(
[
Stream.mkString(Stream.decodeText(handle.stdout)),
Stream.mkString(Stream.decodeText(handle.stderr)),
handle.exitCode,
],
{ concurrency: "unbounded" },
)
if (code !== 0 && code !== 1 && code !== 2) {
return yield* Effect.fail(new Error(`ripgrep failed: ${stderr}`))
}
const items = stdout
.trim()
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line))
.map((parsed) => Result.parse(parsed))
.filter((row): row is Match => row.type === "match")
.map((row) => row.data)
return {
items,
partial: code === 2,
}
}),
)
})
return Service.of({
files: (input) => Stream.unwrap(files(input)),
search,
})
}),
)
@@ -401,46 +488,4 @@ export namespace Ripgrep {
return lines.join("\n")
}
export async function search(input: {
cwd: string
pattern: string
glob?: string[]
limit?: number
follow?: boolean
}) {
const args = [`${await filepath()}`, "--json", "--hidden", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
if (input.glob) {
for (const g of input.glob) {
args.push(`--glob=${g}`)
}
}
if (input.limit) {
args.push(`--max-count=${input.limit}`)
}
args.push("--")
args.push(input.pattern)
const result = await Process.text(args, {
cwd: input.cwd,
nothrow: true,
})
if (result.code !== 0) {
return []
}
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = result.text.trim().split(/\r?\n/).filter(Boolean)
// Parse JSON lines from ripgrep output
return lines
.map((line) => JSON.parse(line))
.map((parsed) => Result.parse(parsed))
.filter((r) => r.type === "match")
.map((r) => r.data)
}
}

View File

@@ -1,6 +1,5 @@
import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema"
@@ -112,22 +111,4 @@ export namespace FileTime {
).pipe(Layer.orDie)
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)))
}
}

View File

@@ -8,7 +8,6 @@ import z from "zod"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Flag } from "@/flag/flag"
import { Git } from "@/git"
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))
const { runPromise } = makeRuntime(Service, defaultLayer)
export function init() {
return runPromise((svc) => svc.init())
}
}

View File

@@ -1,4 +1,3 @@
import { text } from "node:stream/consumers"
import { Npm } from "@/npm"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
@@ -217,26 +216,16 @@ export const rlang: Info = {
name: "air",
extensions: [".R"],
async enabled() {
const airPath = which("air")
if (airPath == null) return false
const air = which("air")
if (air == null) return false
try {
const proc = Process.spawn(["air", "--help"], {
stdout: "pipe",
stderr: "pipe",
})
await proc.exited
if (!proc.stdout) return false
const output = await text(proc.stdout)
const output = await Process.text([air, "--help"], { nothrow: true })
// Check for "Air: An R language server and formatter"
const firstLine = output.split("\n")[0]
const hasR = firstLine.includes("R language")
const hasFormatter = firstLine.includes("formatter")
if (hasR && hasFormatter) return ["air", "format", "$FILE"]
} catch {
return false
}
// Check for "Air: An R language server and formatter"
const firstLine = output.text.split("\n")[0]
const hasR = firstLine.includes("R language")
const hasFormatter = firstLine.includes("formatter")
if (output.code === 0 && hasR && hasFormatter) return [air, "format", "$FILE"]
return false
},
}
@@ -246,11 +235,10 @@ export const uvformat: Info = {
extensions: [".py", ".pyi"],
async enabled() {
if (await ruff.enabled()) return false
if (which("uv") !== null) {
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const code = await proc.exited
if (code === 0) return ["uv", "format", "--", "$FILE"]
}
const uv = which("uv")
if (uv == null) return false
const output = await Process.run([uv, "format", "--help"], { nothrow: true })
if (output.code === 0) return [uv, "format", "--", "$FILE"]
return false
},
}

View File

@@ -5,6 +5,7 @@ import { iife } from "@/util/iife"
import { Log } from "../../util/log"
import { setTimeout as sleep } from "node:timers/promises"
import { CopilotModels } from "./models"
import { MessageV2 } from "@/session/message-v2"
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"
}
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 {
...model,
api: {
...model.api,
url,
npm: "@ai-sdk/github-copilot",
},
}
@@ -44,19 +61,23 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
id: "github-copilot",
async models(provider, ctx) {
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(
base(ctx.auth.enterpriseUrl),
base(auth.enterpriseUrl),
{
Authorization: `Bearer ${ctx.auth.refresh}`,
Authorization: `Bearer ${auth.refresh}`,
"User-Agent": `opencode/${Installation.VERSION}`,
},
provider.models,
).catch((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()
if (!info || info.type !== "oauth") return {}
const baseURL = base(info.enterpriseUrl)
return {
baseURL,
apiKey: "",
async fetch(request: RequestInfo | URL, init?: RequestInit) {
const info = await getAuth()
@@ -88,7 +106,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
(msg: any) =>
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) =>
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")),
),
),
isAgent: !(last?.role === "user" && hasNonToolCalls),
isAgent: !(last?.role === "user" && hasNonToolCalls) || imgMsg(last),
}
}
} catch {}

View File

@@ -52,13 +52,15 @@ export namespace CopilotModels {
(remote.capabilities.supports.vision ?? false) ||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
return {
id: key,
providerID: "github-copilot",
api: {
id: remote.id,
url,
npm: "@ai-sdk/github-copilot",
url: isMsgApi ? `${url}/v1` : url,
npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot",
},
// API response wins
status: "active",

View File

@@ -124,7 +124,7 @@ export namespace Plugin {
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
}
: undefined,
fetch: async (...args) => Server.Default().app.fetch(...args),
fetch: async (...args) => (await Server.Default()).app.fetch(...args),
})
const cfg = yield* config.get()
const input: PluginInput = {
@@ -210,13 +210,15 @@ export namespace Plugin {
return message
},
}).pipe(
Effect.catch((message) =>
bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${load.spec}: ${message}`,
}).toObject(),
}),
),
Effect.catch(() => {
// TODO: make proper events for this
// bus.publish(Session.Event.Error, {
// error: new NamedError.Unknown({
// message: `Failed to load plugin ${load.spec}: ${message}`,
// }).toObject(),
// })
return Effect.void
}),
)
}

View File

@@ -2,7 +2,6 @@ import { Plugin } from "../plugin"
import { Format } from "../format"
import { LSP } from "../lsp"
import { File } from "../file"
import { FileWatcher } from "../file/watcher"
import { Snapshot } from "../snapshot"
import { Project } from "./project"
import { Vcs } from "./vcs"
@@ -10,23 +9,26 @@ import { Bus } from "../bus"
import { Command } from "../command"
import { Instance } from "./instance"
import { Log } from "@/util/log"
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
import { FileWatcher } from "@/file/watcher"
import { ShareNext } from "@/share/share-next"
import * as Effect from "effect/Effect"
export async function InstanceBootstrap() {
export const InstanceBootstrap = Effect.gen(function* () {
Log.Default.info("bootstrapping", { directory: Instance.directory })
await Plugin.init()
void BootstrapRuntime.runPromise(ShareNext.Service.use((svc) => svc.init()))
void BootstrapRuntime.runPromise(Format.Service.use((svc) => svc.init()))
await LSP.init()
File.init()
FileWatcher.init()
Vcs.init()
Snapshot.init()
yield* Plugin.Service.use((svc) => svc.init())
yield* ShareNext.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
yield* Format.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
yield* LSP.Service.use((svc) => svc.init())
yield* File.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
yield* FileWatcher.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
yield* Vcs.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
yield* Snapshot.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {
Project.setInitialized(Instance.project.id)
}
})
}
yield* Bus.Service.use((svc) =>
svc.subscribeCallback(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {
Project.setInitialized(Instance.project.id)
}
}),
)
}).pipe(Effect.withSpan("InstanceBootstrap"))

View File

@@ -1,2 +0,0 @@
// Auto-generated by build.ts - do not edit
export declare const snapshot: Record<string, unknown>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -209,6 +209,9 @@ export namespace ProviderTransform {
copilot: {
copilot_cache_control: { type: "ephemeral" },
},
alibaba: {
cacheControl: { type: "ephemeral" },
},
}
for (const msg of unique([...system, ...final])) {
@@ -285,7 +288,8 @@ export namespace ProviderTransform {
model.api.id.includes("claude") ||
model.id.includes("anthropic") ||
model.id.includes("claude") ||
model.api.npm === "@ai-sdk/anthropic") &&
model.api.npm === "@ai-sdk/anthropic" ||
model.api.npm === "@ai-sdk/alibaba") &&
model.api.npm !== "@ai-sdk/gateway"
) {
msgs = applyCaching(msgs, model)
@@ -774,7 +778,10 @@ export namespace ProviderTransform {
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"] = {
type: "enabled",
clear_thinking: false,

View File

@@ -2,7 +2,6 @@ import { Deferred, Effect, Layer, Schema, Context } from "effect"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { SessionID, MessageID } from "@/session/schema"
import { Log } from "@/util/log"
import z from "zod"
@@ -199,26 +198,4 @@ export namespace Question {
)
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())
}
}

View File

@@ -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))
},
}
},
}
},
}

View File

@@ -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
},
}
},
}
},
}

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
}

View File

@@ -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)
},
)
}

View File

@@ -1,6 +1,7 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { AppRuntime } from "../../effect/app-runtime"
import { File } from "../../file"
import { Ripgrep } from "../../file/ripgrep"
import { LSP } from "../../lsp"
@@ -34,12 +35,10 @@ export const FileRoutes = lazy(() =>
),
async (c) => {
const pattern = c.req.valid("query").pattern
const result = await Ripgrep.search({
cwd: Instance.directory,
pattern,
limit: 10,
})
return c.json(result)
const result = await AppRuntime.runPromise(
Ripgrep.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })),
)
return c.json(result.items)
},
)
.get(

View File

@@ -1,53 +1,33 @@
import { describeRoute, resolver, validator } from "hono-openapi"
import { Hono } from "hono"
import { proxy } from "hono/proxy"
import type { UpgradeWebSocket } from "hono/ws"
import z from "zod"
import { createHash } from "node:crypto"
import * as fs from "node:fs/promises"
import { Log } from "../util/log"
import { Format } from "../format"
import { TuiRoutes } from "./routes/tui"
import { Instance } from "../project/instance"
import { Vcs } from "../project/vcs"
import { Agent } from "../agent/agent"
import { Skill } from "../skill"
import { Global } from "../global"
import { LSP } from "../lsp"
import { Command } from "../command"
import { Flag } from "../flag/flag"
import { QuestionRoutes } from "./routes/question"
import { PermissionRoutes } from "./routes/permission"
import { Snapshot } from "@/snapshot"
import { ProjectRoutes } from "./routes/project"
import { SessionRoutes } from "./routes/session"
import { PtyRoutes } from "./routes/pty"
import { McpRoutes } from "./routes/mcp"
import { FileRoutes } from "./routes/file"
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 { Format } from "../../format"
import { TuiRoutes } from "./tui"
import { Instance } from "../../project/instance"
import { Vcs } from "../../project/vcs"
import { Agent } from "../../agent/agent"
import { Skill } from "../../skill"
import { Global } from "../../global"
import { LSP } from "../../lsp"
import { Command } from "../../command"
import { QuestionRoutes } from "./question"
import { PermissionRoutes } from "./permission"
import { ProjectRoutes } from "./project"
import { SessionRoutes } from "./session"
import { PtyRoutes } from "./pty"
import { McpRoutes } from "./mcp"
import { FileRoutes } from "./file"
import { ConfigRoutes } from "./config"
import { ExperimentalRoutes } from "./experimental"
import { ProviderRoutes } from "./provider"
import { EventRoutes } from "./event"
import { WorkspaceRouterMiddleware } from "./middleware"
import { AppRuntime } from "@/effect/app-runtime"
const log = Log.create({ service: "server" })
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 InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()) =>
app
.onError(errorHandler(log))
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
new Hono()
.use(WorkspaceRouterMiddleware(upgrade))
.route("/project", ProjectRoutes())
.route("/pty", PtyRoutes(upgrade))
.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())))
},
)
.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
}
})

View File

@@ -3,15 +3,14 @@ import type { UpgradeWebSocket } from "hono/ws"
import { getAdaptor } from "@/control-plane/adaptors"
import { WorkspaceID } from "@/control-plane/schema"
import { Workspace } from "@/control-plane/workspace"
import { ServerProxy } from "./proxy"
import { lazy } from "@/util/lazy"
import { ServerProxy } from "../proxy"
import { Filesystem } from "@/util/filesystem"
import { Instance } from "@/project/instance"
import { InstanceBootstrap } from "@/project/bootstrap"
import { InstanceRoutes } from "./instance"
import { Session } from "@/session"
import { SessionID } from "@/session/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { AppRuntime } from "@/effect/app-runtime"
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
@@ -47,9 +46,7 @@ async function getSessionWorkspace(url: URL) {
}
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
const routes = lazy(() => InstanceRoutes(upgrade))
return async (c) => {
return async (c, next) => {
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
const directory = Filesystem.resolve(
(() => {
@@ -70,9 +67,9 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
if (!workspaceID) {
return Instance.provide({
directory,
init: InstanceBootstrap,
init: () => AppRuntime.runPromise(InstanceBootstrap),
async fn() {
return routes().fetch(c.req.raw, c.env)
return next()
},
})
}
@@ -87,7 +84,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
// The lets the `DELETE /session/:id` endpoint through and we've
// made sure that it will run without an instance
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}`, {
@@ -107,9 +104,9 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
fn: () =>
Instance.provide({
directory: target.directory,
init: InstanceBootstrap,
init: () => AppRuntime.runPromise(InstanceBootstrap),
async fn() {
return routes().fetch(c.req.raw, c.env)
return next()
},
}),
})
@@ -118,7 +115,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
if (local(c.req.method, url.pathname)) {
// No instance provided because we are serving cached data; there
// is no instance to work with
return routes().fetch(c.req.raw, c.env)
return next()
}
if (c.req.header("upgrade")?.toLowerCase() === "websocket") {

View File

@@ -8,6 +8,7 @@ import { ProjectID } from "../../project/schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { InstanceBootstrap } from "../../project/bootstrap"
import { AppRuntime } from "@/effect/app-runtime"
export const ProjectRoutes = lazy(() =>
new Hono()
@@ -83,7 +84,7 @@ export const ProjectRoutes = lazy(() =>
directory: dir,
worktree: dir,
project: next,
init: InstanceBootstrap,
init: () => AppRuntime.runPromise(InstanceBootstrap),
})
return c.json(next)
},

View File

@@ -3,6 +3,7 @@ import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi"
import { QuestionID } from "@/question/schema"
import { Question } from "../../question"
import { AppRuntime } from "@/effect/app-runtime"
import z from "zod"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -27,7 +28,7 @@ export const QuestionRoutes = lazy(() =>
},
}),
async (c) => {
const questions = await Question.list()
const questions = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
return c.json(questions)
},
)
@@ -59,10 +60,14 @@ export const QuestionRoutes = lazy(() =>
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await Question.reply({
requestID: params.requestID,
answers: json.answers,
})
await AppRuntime.runPromise(
Question.Service.use((svc) =>
svc.reply({
requestID: params.requestID,
answers: json.answers,
}),
),
)
return c.json(true)
},
)
@@ -92,7 +97,7 @@ export const QuestionRoutes = lazy(() =>
),
async (c) => {
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)
},
),

View File

@@ -13,6 +13,7 @@ import { SessionShare } from "@/share/session"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "../../session/todo"
import { Effect } from "effect"
import { AppRuntime } from "../../effect/app-runtime"
import { Agent } from "../../agent/agent"
import { Snapshot } from "@/snapshot"
@@ -273,6 +274,7 @@ export const SessionRoutes = lazy(() =>
"json",
z.object({
title: z.string().optional(),
permission: Permission.Ruleset.optional(),
time: z
.object({
archived: z.number().optional(),
@@ -283,10 +285,17 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const sessionID = c.req.valid("param").sessionID
const updates = c.req.valid("json")
const current = await Session.get(sessionID)
if (updates.title !== undefined) {
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) {
await Session.setArchived({ sessionID, time: updates.time.archived })
}
@@ -716,11 +725,17 @@ export const SessionRoutes = lazy(() =>
),
async (c) => {
const params = c.req.valid("param")
await SessionRunState.assertNotBusy(params.sessionID)
await Session.removeMessage({
sessionID: params.sessionID,
messageID: params.messageID,
})
await AppRuntime.runPromise(
Effect.gen(function* () {
const state = yield* SessionRunState.Service
const session = yield* Session.Service
yield* state.assertNotBusy(params.sessionID)
yield* session.removeMessage({
sessionID: params.sessionID,
messageID: params.messageID,
})
}),
)
return c.json(true)
},
)

View File

@@ -3,31 +3,90 @@ import { NamedError } from "@opencode-ai/util/error"
import { NotFoundError } from "../storage/db"
import { Session } from "../session"
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 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 {
return (err, c) => {
log.error("failed", {
error: err,
})
if (err instanceof NamedError) {
let status: ContentfulStatusCode
if (err instanceof NotFoundError) status = 404
else if (err instanceof Provider.ModelNotFoundError) status = 400
else if (err.name === "ProviderAuthValidationFailed") status = 400
else if (err.name.startsWith("Worktree")) status = 400
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 HTTPException) return err.getResponse()
const message = err instanceof Error && err.stack ? err.stack : err.toString()
return c.json(new NamedError.Unknown({ message }).toObject(), {
status: 500,
const log = Log.create({ service: "server" })
export const ErrorMiddleware: ErrorHandler = (err, c) => {
log.error("failed", {
error: err,
})
if (err instanceof NamedError) {
let status: ContentfulStatusCode
if (err instanceof NotFoundError) status = 404
else if (err instanceof Provider.ModelNotFoundError) status = 400
else if (err.name === "ProviderAuthValidationFailed") status = 400
else if (err.name.startsWith("Worktree")) status = 400
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 HTTPException) return err.getResponse()
const message = err instanceof Error && err.stack ? err.stack : err.toString()
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)
}

View File

@@ -1,24 +1,14 @@
import { Log } from "../util/log"
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
import { generateSpecs } from "hono-openapi"
import { Hono } from "hono"
import { compress } from "hono/compress"
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 { adapter } from "#hono"
import { MDNS } from "./mdns"
import { lazy } from "@/util/lazy"
import { errorHandler } from "./middleware"
import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
import { InstanceRoutes } from "./instance"
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
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -26,6 +16,8 @@ globalThis.AI_SDK_LOG_WARNINGS = false
initProjectors()
export namespace Server {
const log = Log.create({ service: "server" })
export type Listener = {
hostname: string
port: number
@@ -33,231 +25,31 @@ export namespace Server {
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 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[] }) {
const app = new Hono()
const ws = createNodeWebSocket({ app })
const runtime = adapter.create(app)
return {
app: ControlPlaneRoutes(ws.upgradeWebSocket, app, opts),
ws,
app: app
.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() {
// Build a fresh app with all routes registered directly so
// hono-openapi can see describeRoute metadata (`.route()` wraps
// handlers when the sub-app has a custom errorHandler, which
// strips the metadata symbol).
const { app, ws } = create({})
InstanceRoutes(ws.upgradeWebSocket, app)
const { app } = create({})
const result = await generateSpecs(app, {
documentation: {
info: {
@@ -281,46 +73,21 @@ export namespace Server {
cors?: string[]
}): Promise<Listener> {
const built = create(opts)
const start = (port: number) =>
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 server = await built.runtime.listen(opts)
const next = new URL("http://localhost")
next.hostname = opts.hostname
next.port = String(addr.port)
next.port = String(server.port)
url = next
const mdns =
opts.mdns &&
addr.port &&
server.port &&
opts.hostname !== "127.0.0.1" &&
opts.hostname !== "localhost" &&
opts.hostname !== "::1"
if (mdns) {
MDNS.publish(addr.port, opts.mdnsDomain)
MDNS.publish(server.port, opts.mdnsDomain)
} else if (opts.mdns) {
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
}
@@ -328,27 +95,13 @@ export namespace Server {
let closing: Promise<void> | undefined
return {
hostname: opts.hostname,
port: addr.port,
port: server.port,
url: next,
stop(close?: boolean) {
closing ??= new Promise((resolve, reject) => {
closing ??= (async () => {
if (mdns) MDNS.unpublish()
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()
}
}
})
await server.stop(close)
})()
return closing
},
}

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
}
})

View File

@@ -4,7 +4,7 @@ import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Decimal } from "decimal.js"
import z from "zod"
import { type ProviderMetadata } from "ai"
import { type ProviderMetadata, type LanguageModelUsage } from "ai"
import { Flag } from "../flag/flag"
import { Installation } from "../installation"
@@ -28,7 +28,6 @@ import { SessionID, MessageID, PartID } from "./schema"
import type { Provider } from "@/provider/provider"
import { Permission } from "@/permission"
import { Global } from "@/global"
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
import { Effect, Layer, Option, Context } from "effect"
import { makeRuntime } from "@/effect/run-service"
@@ -240,7 +239,7 @@ export namespace Session {
export const getUsage = (input: {
model: Provider.Model
usage: LanguageModelV2Usage
usage: LanguageModelUsage
metadata?: ProviderMetadata
}) => {
const safe = (value: number) => {
@@ -249,11 +248,14 @@ export namespace Session {
}
const inputTokens = safe(input.usage.inputTokens ?? 0)
const outputTokens = safe(input.usage.outputTokens ?? 0)
const reasoningTokens = safe(input.usage.reasoningTokens ?? 0)
const reasoningTokens = safe(input.usage.outputTokenDetails?.reasoningTokens ?? input.usage.reasoningTokens ?? 0)
const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? 0)
const cacheReadInputTokens = safe(
input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0,
)
const cacheWriteInputTokens = safe(
(input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
(input.usage.inputTokenDetails?.cacheWriteTokens ??
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// google-vertex-anthropic returns metadata under "vertex" key
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ??
@@ -274,7 +276,7 @@ export namespace Session {
const tokens = {
total,
input: adjustedInputTokens,
output: outputTokens - reasoningTokens,
output: safe(outputTokens - reasoningTokens),
reasoning: reasoningTokens,
cache: {
write: cacheWriteInputTokens,
@@ -708,6 +710,10 @@ export namespace Session {
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(
z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }),
(input) =>

View File

@@ -4,7 +4,6 @@ import { Effect, Layer, Context } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Flag } from "@/flag/flag"
import { AppFileSystem } from "@/filesystem"
import { withTransientReadRetry } from "@/util/effect-http-client"
@@ -238,21 +237,7 @@ export namespace Instruction {
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[]) {
return extract(messages)
}
export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: MessageID) {
return runPromise((svc) => svc.resolve(messages, filepath, messageID))
}
}

View File

@@ -25,6 +25,8 @@ interface FetchDecompressionError extends Error {
}
export namespace MessageV2 {
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:"
export function isMedia(mime: string) {
return mime.startsWith("image/") || mime === "application/pdf"
}
@@ -808,7 +810,7 @@ export namespace MessageV2 {
parts: [
{
type: "text" as const,
text: "Attached image(s) from tool result:",
text: SYNTHETIC_ATTACHMENT_PROMPT,
},
...media.map((attachment) => ({
type: "file" as const,

View File

@@ -1,6 +1,5 @@
import { InstanceState } from "@/effect/instance-state"
import { Runner } from "@/effect/runner"
import { makeRuntime } from "@/effect/run-service"
import { Effect, Layer, Scope, Context } from "effect"
import { Session } from "."
import { MessageV2 } from "./message-v2"
@@ -106,9 +105,4 @@ export namespace SessionRunState {
)
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))
}
}

View File

@@ -177,8 +177,39 @@ export namespace Snapshot {
const all = Array.from(new Set([...tracked, ...untracked]))
if (!all.length) return
// Filter out files that are now gitignored even if previously tracked
// Files may have been tracked before being gitignored, so we need to check
// against the source project's current gitignore rules
// Use --no-index to check purely against patterns (ignoring whether file is tracked)
const checkArgs = [
...quote,
"--git-dir",
path.join(state.worktree, ".git"),
"--work-tree",
state.worktree,
"check-ignore",
"--no-index",
"--",
...all,
]
const check = yield* git(checkArgs, { cwd: state.directory })
const ignored =
check.code === 0 ? new Set(check.text.trim().split("\n").filter(Boolean)) : new Set<string>()
const filtered = all.filter((item) => !ignored.has(item))
// Remove newly-ignored files from snapshot index to prevent re-adding
if (ignored.size > 0) {
const ignoredFiles = Array.from(ignored)
log.info("removing gitignored files from snapshot", { count: ignoredFiles.length })
yield* git([...cfg, ...args(["rm", "--cached", "-f", "--", ...ignoredFiles])], {
cwd: state.directory,
})
}
if (!filtered.length) return
const large = (yield* Effect.all(
all.map((item) =>
filtered.map((item) =>
fs
.stat(path.join(state.directory, item))
.pipe(Effect.catch(() => Effect.void))
@@ -259,14 +290,39 @@ export namespace Snapshot {
log.warn("failed to get diff", { hash, exitCode: result.code })
return { hash, files: [] }
}
const files = result.text
.trim()
.split("\n")
.map((x) => x.trim())
.filter(Boolean)
// Filter out files that are now gitignored
if (files.length > 0) {
const checkArgs = [
...quote,
"--git-dir",
path.join(state.worktree, ".git"),
"--work-tree",
state.worktree,
"check-ignore",
"--no-index",
"--",
...files,
]
const check = yield* git(checkArgs, { cwd: state.directory })
if (check.code === 0) {
const ignored = new Set(check.text.trim().split("\n").filter(Boolean))
const filtered = files.filter((item) => !ignored.has(item))
return {
hash,
files: filtered.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
}
}
}
return {
hash,
files: result.text
.trim()
.split("\n")
.map((x) => x.trim())
.filter(Boolean)
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
files: files.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
}
}),
)
@@ -616,6 +672,30 @@ export namespace Snapshot {
} satisfies Row,
]
})
// Filter out files that are now gitignored
if (rows.length > 0) {
const files = rows.map((r) => r.file)
const checkArgs = [
...quote,
"--git-dir",
path.join(state.worktree, ".git"),
"--work-tree",
state.worktree,
"check-ignore",
"--no-index",
"--",
...files,
]
const check = yield* git(checkArgs, { cwd: state.directory })
if (check.code === 0) {
const ignored = new Set(check.text.trim().split("\n").filter(Boolean))
const filtered = rows.filter((r) => !ignored.has(r.file))
rows.length = 0
rows.push(...filtered)
}
}
const step = 100
const patch = (file: string, before: string, after: string) =>
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))

View File

@@ -1,11 +1,8 @@
import z from "zod"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { Effect, Option } from "effect"
import { Tool } from "./tool"
import { Filesystem } from "../util/filesystem"
import { Ripgrep } from "../file/ripgrep"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { AppFileSystem } from "../filesystem"
import DESCRIPTION from "./grep.txt"
import { Instance } from "../project/instance"
@@ -17,7 +14,8 @@ const MAX_LINE_LENGTH = 2000
export const GrepTool = Tool.define(
"grep",
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const fs = yield* AppFileSystem.Service
const rg = yield* Ripgrep.Service
return {
description: DESCRIPTION,
@@ -28,6 +26,11 @@ export const GrepTool = Tool.define(
}),
execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) =>
Effect.gen(function* () {
const empty = {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
if (!params.pattern) {
throw new Error("pattern is required")
}
@@ -43,92 +46,58 @@ export const GrepTool = Tool.define(
},
})
let searchPath = params.path ?? Instance.directory
searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
const searchPath = AppFileSystem.resolve(
path.isAbsolute(params.path ?? Instance.directory)
? (params.path ?? Instance.directory)
: path.join(Instance.directory, params.path ?? "."),
)
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
const rgPath = yield* Effect.promise(() => Ripgrep.filepath())
const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
if (params.include) {
args.push("--glob", params.include)
}
args.push(searchPath)
const result = yield* rg.search({
cwd: searchPath,
pattern: params.pattern,
glob: params.include ? [params.include] : undefined,
})
const result = yield* Effect.scoped(
Effect.gen(function* () {
const handle = yield* spawner.spawn(
ChildProcess.make(rgPath, args, {
stdin: "ignore",
}),
)
if (result.items.length === 0) return empty
const [output, errorOutput] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const exitCode = yield* handle.exitCode
return { output, errorOutput, exitCode }
}),
const rows = result.items.map((item) => ({
path: AppFileSystem.resolve(
path.isAbsolute(item.path.text) ? item.path.text : path.join(searchPath, item.path.text),
),
line: item.line_number,
text: item.lines.text,
}))
const times = new Map(
(yield* Effect.forEach(
[...new Set(rows.map((row) => row.path))],
Effect.fnUntraced(function* (file) {
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!info || info.type === "Directory") return undefined
return [
file,
info.mtime.pipe(
Option.map((time) => time.getTime()),
Option.getOrElse(() => 0),
) ?? 0,
] as const
}),
{ concurrency: 16 },
)).filter((entry): entry is readonly [string, number] => Boolean(entry)),
)
const matches = rows.flatMap((row) => {
const mtime = times.get(row.path)
if (mtime === undefined) return []
return [{ ...row, mtime }]
})
const { output, errorOutput, exitCode } = result
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
// Only fail if exit code is 2 AND no output was produced
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
return {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
if (exitCode !== 0 && exitCode !== 2) {
throw new Error(`ripgrep failed: ${errorOutput}`)
}
const hasErrors = exitCode === 2
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = output.trim().split(/\r?\n/)
const matches = []
for (const line of lines) {
if (!line) continue
const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
const lineNum = parseInt(lineNumStr, 10)
const lineText = lineTextParts.join("|")
const stats = Filesystem.stat(filePath)
if (!stats) continue
matches.push({
path: filePath,
modTime: stats.mtime.getTime(),
lineNum,
lineText,
})
}
matches.sort((a, b) => b.modTime - a.modTime)
matches.sort((a, b) => b.mtime - a.mtime)
const limit = 100
const truncated = matches.length > limit
const finalMatches = truncated ? matches.slice(0, limit) : matches
if (finalMatches.length === 0) {
return {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
if (finalMatches.length === 0) return empty
const totalMatches = matches.length
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
@@ -143,10 +112,8 @@ export const GrepTool = Tool.define(
outputLines.push(`${match.path}:`)
}
const truncatedLineText =
match.lineText.length > MAX_LINE_LENGTH
? match.lineText.substring(0, MAX_LINE_LENGTH) + "..."
: match.lineText
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
match.text.length > MAX_LINE_LENGTH ? match.text.substring(0, MAX_LINE_LENGTH) + "..." : match.text
outputLines.push(` Line ${match.line}: ${truncatedLineText}`)
}
if (truncated) {
@@ -156,7 +123,7 @@ export const GrepTool = Tool.define(
)
}
if (hasErrors) {
if (result.partial) {
outputLines.push("")
outputLines.push("(Some paths were inaccessible and skipped)")
}

View File

@@ -70,7 +70,12 @@ export namespace Tool {
? Def<P, M>
: never
function wrap<Parameters extends z.ZodType, Result extends Metadata>(id: string, init: Init<Parameters, Result>) {
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
id: string,
init: Init<Parameters, Result>,
truncate: Truncate.Interface,
agents: Agent.Interface,
) {
return () =>
Effect.gen(function* () {
const toolInfo = init instanceof Function ? { ...(yield* init()) } : { ...init }
@@ -93,8 +98,8 @@ export namespace Tool {
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)
const truncated = yield* truncate.output(result.output, {}, agent)
return {
...result,
output: truncated.content,
@@ -112,9 +117,14 @@ export namespace Tool {
export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
id: ID,
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(
Effect.map(init, (init) => ({ id, init: wrap(id, init) })),
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 },
)
}

View File

@@ -2,7 +2,6 @@ import { NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Schedule, Context } from "effect"
import path from "path"
import type { Agent } from "../agent/agent"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { evaluate } from "@/permission/evaluate"
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))
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))
}
}

View File

@@ -15,6 +15,7 @@ export namespace Log {
WARN: 2,
ERROR: 3,
}
const keep = 10
let level: Level = "INFO"
@@ -78,15 +79,19 @@ export namespace Log {
}
async function cleanup(dir: string) {
const files = await Glob.scan("????-??-??T??????.log", {
cwd: dir,
absolute: true,
include: "file",
})
if (files.length <= 5) return
const files = (
await Glob.scan("????-??-??T??????.log", {
cwd: dir,
absolute: false,
include: "file",
}).catch(() => [])
)
.filter((file) => path.basename(file) === file)
.sort()
if (files.length <= keep) return
const filesToDelete = files.slice(0, -10)
await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {})))
const doomed = files.slice(0, -keep)
await Promise.all(doomed.map((file) => fs.unlink(path.join(dir, file)).catch(() => {})))
}
function formatError(error: Error, depth = 0): string {

View File

@@ -10,59 +10,106 @@ export namespace Message {
})),
)
export class File extends Schema.Class<File>("Message.File")({
url: Schema.String,
export class Source extends Schema.Class<Source>("Message.Source")({
start: Schema.Number,
end: Schema.Number,
text: Schema.String,
}) {}
export class FileAttachment extends Schema.Class<FileAttachment>("Message.File.Attachment")({
uri: Schema.String,
mime: Schema.String,
name: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
source: Source.pipe(Schema.optional),
}) {
static create(url: string) {
return new File({
url,
return new FileAttachment({
uri: 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 AgentAttachment extends Schema.Class<AgentAttachment>("Message.Agent.Attachment")({
name: Schema.String,
source: Source.pipe(Schema.optional),
}) {}
export class User extends Schema.Class<User>("Message.User")({
id: ID,
type: Schema.Literal("user"),
text: Schema.String,
files: Schema.Array(FileAttachment).pipe(Schema.optional),
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
time: Schema.Struct({
created: Schema.DateTimeUtc,
}),
content: UserContent,
}) {
static create(content: Schema.Schema.Type<typeof UserContent>) {
static create(input: { text: User["text"]; files?: User["files"]; agents?: User["agents"] }) {
const msg = new User({
id: ID.create(),
type: "user",
...input,
time: {
created: Effect.runSync(DateTime.now),
},
content,
})
return msg
}
static file(url: string) {
return new File({
url,
mime: "text/plain",
})
}
}
export namespace User {}
export class Synthetic extends Schema.Class<Synthetic>("Message.Synthetic")({
id: ID,
type: Schema.Literal("synthetic"),
text: Schema.String,
time: Schema.Struct({
created: Schema.DateTimeUtc,
}),
}) {}
export class Request extends Schema.Class<Request>("Message.Request")({
id: ID,
type: Schema.Literal("start"),
model: Schema.Struct({
id: Schema.String,
providerID: Schema.String,
variant: Schema.String.pipe(Schema.optional),
}),
time: Schema.Struct({
created: Schema.DateTimeUtc,
}),
}) {}
export class Text extends Schema.Class<Text>("Message.Text")({
id: ID,
type: Schema.Literal("text"),
text: Schema.String,
time: Schema.Struct({
created: Schema.DateTimeUtc,
completed: Schema.DateTimeUtc.pipe(Schema.optional),
}),
}) {}
export class Complete extends Schema.Class<Complete>("Message.Complete")({
id: ID,
type: Schema.Literal("complete"),
time: Schema.Struct({
created: Schema.DateTimeUtc,
}),
cost: Schema.Number,
tokens: Schema.Struct({
total: Schema.Number,
input: Schema.Number,
output: Schema.Number,
reasoning: Schema.Number,
cache: Schema.Struct({
read: Schema.Number,
write: Schema.Number,
}),
}),
}) {}
export const Info = Schema.Union([User, Text])
export type Info = Schema.Schema.Type<typeof Info>
}
const msg = Message.User.create({
text: "Hello world",
files: [Message.File.create("file://example.com/file.txt")],
})
console.log(JSON.stringify(msg, null, 2))

View File

@@ -0,0 +1,71 @@
import { Context, Layer, Schema, Effect } from "effect"
import { Message } from "./message"
import { Struct } from "effect"
import { Identifier } from "@/id/id"
import { withStatics } from "@/util/schema"
import { Session } from "@/session"
import { SessionID } from "@/session/schema"
export namespace SessionV2 {
export const ID = SessionID
export type ID = Schema.Schema.Type<typeof ID>
export class PromptInput extends Schema.Class<PromptInput>("Session.PromptInput")({
...Struct.omit(Message.User.fields, ["time", "type"]),
id: Schema.optionalKey(Message.ID),
sessionID: SessionV2.ID,
}) {}
export class CreateInput extends Schema.Class<CreateInput>("Session.CreateInput")({
id: Schema.optionalKey(SessionV2.ID),
}) {}
export class Info extends Schema.Class<Info>("Session.Info")({
id: SessionV2.ID,
model: Schema.Struct({
id: Schema.String,
providerID: Schema.String,
modelID: Schema.String,
}).pipe(Schema.optional),
}) {}
export interface Interface {
fromID: (id: SessionV2.ID) => Effect.Effect<Info>
create: (input: CreateInput) => Effect.Effect<Info>
prompt: (input: PromptInput) => Effect.Effect<Message.User>
}
export class Service extends Context.Service<Service, Interface>()("Session.Service") {}
export const layer = Layer.effect(Service)(
Effect.gen(function* () {
const session = yield* Session.Service
const create: Interface["create"] = Effect.fn("Session.create")(function* (input) {
throw new Error("Not implemented")
})
const prompt: Interface["prompt"] = Effect.fn("Session.prompt")(function* (input) {
throw new Error("Not implemented")
})
const fromID: Interface["fromID"] = Effect.fn("Session.fromID")(function* (id) {
const match = yield* session.get(id)
return fromV1(match)
})
return Service.of({
create,
prompt,
fromID,
})
}),
)
function fromV1(input: Session.Info): Info {
return new Info({
id: SessionV2.ID.make(input.id),
})
}
}

View File

@@ -20,6 +20,7 @@ import { AppFileSystem } from "@/filesystem"
import { makeRuntime } from "@/effect/run-service"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect/instance-state"
import { AppRuntime } from "@/effect/app-runtime"
export namespace Worktree {
const log = Log.create({ service: "worktree" })
@@ -266,7 +267,7 @@ export namespace Worktree {
const booted = yield* Effect.promise(() =>
Instance.provide({
directory: info.directory,
init: InstanceBootstrap,
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: () => undefined,
})
.then(() => true)

View File

@@ -79,3 +79,55 @@ await using tmp = await tmpdir({
- 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
- 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.

View File

@@ -6,7 +6,6 @@ import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { Global } from "../../../src/global"
import { TuiConfig } from "../../../src/config/tui"
import { Config } from "../../../src/config/config"
import { Filesystem } from "../../../src/util/filesystem"
const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
@@ -325,7 +324,6 @@ export default {
})
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const install = spyOn(Config, "installDependencies").mockResolvedValue()
try {
expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true)
@@ -407,7 +405,6 @@ export default {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
install.mockRestore()
if (backup === undefined) {
await fs.rm(globalConfigPath, { force: true })
} else {
@@ -701,7 +698,6 @@ test("updates installed theme when plugin metadata changes", async () => {
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const install = spyOn(Config, "installDependencies").mockResolvedValue()
const api = () =>
createTuiPluginApi({
@@ -746,7 +742,6 @@ test("updates installed theme when plugin metadata changes", async () => {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
install.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})

View File

@@ -1,5 +1,5 @@
import { test, expect, describe, mock, afterEach, beforeEach, spyOn } from "bun:test"
import { Effect, Layer, Option } from "effect"
import { Deferred, Effect, Fiber, Layer, Option } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Config } from "../../src/config/config"
import { Instance } from "../../src/project/instance"
@@ -7,8 +7,9 @@ import { Auth } from "../../src/auth"
import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
import { AppFileSystem } from "../../src/filesystem"
import { provideTmpdirInstance } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture"
import { tmpdir, tmpdirScoped } from "../fixture/fixture"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { testEffect } from "../lib/effect"
/** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */
const infra = CrossSpawnSpawner.defaultLayer.pipe(
@@ -32,6 +33,18 @@ const emptyAuth = Layer.mock(Auth.Service)({
all: () => Effect.succeed({}),
})
const it = testEffect(
Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(emptyAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
),
)
const installDeps = (dir: string, input?: Config.InstallInput) =>
Config.Service.use((svc) => svc.installDependencies(dir, input))
// Get managed config directory from environment (set in preload.ts)
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
@@ -817,128 +830,134 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
}
})
test("dedupes concurrent config dependency installs for the same dir", async () => {
await using tmp = await tmpdir()
const dir = path.join(tmp.path, "a")
await fs.mkdir(dir, { recursive: true })
it.live("dedupes concurrent config dependency installs for the same dir", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const dir = path.join(tmp, "a")
yield* Effect.promise(() => fs.mkdir(dir, { recursive: true }))
const ticks: number[] = []
let calls = 0
let start = () => {}
let done = () => {}
let blocked = () => {}
const ready = new Promise<void>((resolve) => {
start = resolve
})
const gate = new Promise<void>((resolve) => {
done = resolve
})
const waiting = new Promise<void>((resolve) => {
blocked = resolve
})
const online = spyOn(Network, "online").mockReturnValue(false)
const targetDir = dir
const run = spyOn(Npm, "install").mockImplementation(async (d: string) => {
const hit = path.normalize(d) === path.normalize(targetDir)
if (hit) {
let calls = 0
const online = spyOn(Network, "online").mockReturnValue(false)
const ready = Deferred.makeUnsafe<void>()
const blocked = Deferred.makeUnsafe<void>()
const hold = Deferred.makeUnsafe<void>()
const target = path.normalize(dir)
const run = spyOn(Npm, "install").mockImplementation(async (d: string) => {
if (path.normalize(d) !== target) return
calls += 1
start()
await gate
}
const mod = path.join(d, "node_modules", "@opencode-ai", "plugin")
await fs.mkdir(mod, { recursive: true })
await Filesystem.write(
path.join(mod, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
)
if (hit) {
start()
await gate
}
})
try {
const first = Config.installDependencies(dir)
await ready
const second = Config.installDependencies(dir, {
waitTick: (tick) => {
ticks.push(tick.attempt)
blocked()
blocked = () => {}
},
Deferred.doneUnsafe(ready, Effect.void)
await Effect.runPromise(Deferred.await(hold))
const mod = path.join(d, "node_modules", "@opencode-ai", "plugin")
await fs.mkdir(mod, { recursive: true })
await Filesystem.write(
path.join(mod, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
)
})
await waiting
done()
await Promise.all([first, second])
} finally {
online.mockRestore()
run.mockRestore()
}
expect(calls).toBe(2)
expect(ticks.length).toBeGreaterThan(0)
expect(await Filesystem.exists(path.join(dir, "package.json"))).toBe(true)
})
test("serializes config dependency installs across dirs", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const a = path.join(tmp.path, "a")
const b = path.join(tmp.path, "b")
await fs.mkdir(a, { recursive: true })
await fs.mkdir(b, { recursive: true })
let calls = 0
let open = 0
let peak = 0
let start = () => {}
let done = () => {}
const ready = new Promise<void>((resolve) => {
start = resolve
})
const gate = new Promise<void>((resolve) => {
done = resolve
})
const online = spyOn(Network, "online").mockReturnValue(false)
const run = spyOn(Npm, "install").mockImplementation(async (dir: string) => {
const cwd = path.normalize(dir)
const hit = cwd === path.normalize(a) || cwd === path.normalize(b)
if (hit) {
calls += 1
open += 1
peak = Math.max(peak, open)
if (calls === 1) {
start()
await gate
}
}
const mod = path.join(cwd, "node_modules", "@opencode-ai", "plugin")
await fs.mkdir(mod, { recursive: true })
await Filesystem.write(
path.join(mod, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
online.mockRestore()
run.mockRestore()
}),
)
if (hit) {
open -= 1
}
})
try {
const first = Config.installDependencies(a)
await ready
const second = Config.installDependencies(b)
done()
await Promise.all([first, second])
} finally {
online.mockRestore()
run.mockRestore()
}
const first = yield* installDeps(dir).pipe(Effect.forkScoped)
yield* Deferred.await(ready)
expect(calls).toBe(2)
expect(peak).toBe(1)
})
let done = false
const second = yield* installDeps(dir, {
waitTick: () => {
Deferred.doneUnsafe(blocked, Effect.void)
},
}).pipe(
Effect.tap(() =>
Effect.sync(() => {
done = true
}),
),
Effect.forkScoped,
)
yield* Deferred.await(blocked)
expect(done).toBe(false)
yield* Deferred.succeed(hold, void 0)
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(calls).toBe(1)
expect(yield* Effect.promise(() => Filesystem.exists(path.join(dir, "package.json")))).toBe(true)
}),
)
it.live("serializes config dependency installs across dirs", () =>
Effect.gen(function* () {
if (process.platform !== "win32") return
const tmp = yield* tmpdirScoped()
const a = path.join(tmp, "a")
const b = path.join(tmp, "b")
yield* Effect.promise(() => fs.mkdir(a, { recursive: true }))
yield* Effect.promise(() => fs.mkdir(b, { recursive: true }))
let calls = 0
let open = 0
let peak = 0
const ready = Deferred.makeUnsafe<void>()
const blocked = Deferred.makeUnsafe<void>()
const hold = Deferred.makeUnsafe<void>()
const online = spyOn(Network, "online").mockReturnValue(false)
const run = spyOn(Npm, "install").mockImplementation(async (dir: string) => {
const cwd = path.normalize(dir)
const hit = cwd === path.normalize(a) || cwd === path.normalize(b)
if (hit) {
calls += 1
open += 1
peak = Math.max(peak, open)
if (calls === 1) {
Deferred.doneUnsafe(ready, Effect.void)
await Effect.runPromise(Deferred.await(hold))
}
}
const mod = path.join(cwd, "node_modules", "@opencode-ai", "plugin")
await fs.mkdir(mod, { recursive: true })
await Filesystem.write(
path.join(mod, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
)
if (hit) {
open -= 1
}
})
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
online.mockRestore()
run.mockRestore()
}),
)
const first = yield* installDeps(a).pipe(Effect.forkScoped)
yield* Deferred.await(ready)
const second = yield* installDeps(b, {
waitTick: () => {
Deferred.doneUnsafe(blocked, Effect.void)
},
}).pipe(Effect.forkScoped)
yield* Deferred.await(blocked)
expect(peak).toBe(1)
yield* Deferred.succeed(hold, void 0)
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(calls).toBe(2)
expect(peak).toBe(1)
}),
)
test("resolves scoped npm plugins in config", async () => {
await using tmp = await tmpdir({

View File

@@ -38,7 +38,9 @@ describe("file.ripgrep", () => {
expect(hasVisible).toBe(true)
expect(hasHidden).toBe(false)
})
})
describe("Ripgrep.Service", () => {
test("search returns empty when nothing matches", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -46,16 +48,34 @@ describe("file.ripgrep", () => {
},
})
const hits = await Ripgrep.search({
cwd: tmp.path,
pattern: "needle",
const result = await Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg.search({ cwd: tmp.path, pattern: "needle" })
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
expect(result.partial).toBe(false)
expect(result.items).toEqual([])
})
test("search returns matched rows", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n")
await Bun.write(path.join(dir, "skip.txt"), "const value = 'other'\n")
},
})
expect(hits).toEqual([])
})
})
const result = await Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg.search({ cwd: tmp.path, pattern: "needle", glob: ["*.ts"] })
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
expect(result.partial).toBe(false)
expect(result.items).toHaveLength(1)
expect(result.items[0]?.path.text).toContain("match.ts")
expect(result.items[0]?.lines.text).toContain("needle")
})
describe("Ripgrep.Service", () => {
test("files returns stream of filenames", async () => {
await using tmp = await tmpdir({
init: async (dir) => {

View File

@@ -1,445 +1,422 @@
import { describe, test, expect, afterEach } from "bun:test"
import path from "path"
import { afterEach, describe, expect } from "bun:test"
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 { Instance } from "../../src/project/instance"
import { SessionID } from "../../src/session/schema"
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 () => {
await Instance.disposeAll()
})
async function touch(file: string, time: number) {
const date = new Date(time)
await fs.utimes(file, date, date)
}
const it = testEffect(Layer.mergeAll(FileTime.defaultLayer, CrossSpawnSpawner.defaultLayer))
function gate() {
let open!: () => void
const wait = new Promise<void>((resolve) => {
open = resolve
const id = SessionID.make("ses_00000000000000000000000001")
const put = (file: string, text: string) => Effect.promise(() => fs.writeFile(file, text, "utf-8"))
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", () => {
const sessionID = SessionID.make("ses_00000000000000000000000001")
describe("read() and get()", () => {
test("stores read timestamp", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
it.live("stores read timestamp", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await FileTime.get(sessionID, filepath)
const before = yield* get(id, file)
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!.getTime()).toBeGreaterThan(0)
},
})
})
}),
),
)
test("tracks separate timestamps per session", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
it.live("tracks separate timestamps per session", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(SessionID.make("ses_00000000000000000000000002"), filepath)
await FileTime.read(SessionID.make("ses_00000000000000000000000003"), filepath)
const one = SessionID.make("ses_00000000000000000000000002")
const two = SessionID.make("ses_00000000000000000000000003")
yield* read(one, file)
yield* read(two, file)
const time1 = await FileTime.get(SessionID.make("ses_00000000000000000000000002"), filepath)
const time2 = await FileTime.get(SessionID.make("ses_00000000000000000000000003"), filepath)
const first = yield* get(one, file)
const second = yield* get(two, file)
expect(time1).toBeDefined()
expect(time2).toBeDefined()
},
})
})
expect(first).toBeDefined()
expect(second).toBeDefined()
}),
),
)
test("updates timestamp on subsequent reads", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
it.live("updates timestamp on subsequent reads", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
const first = await FileTime.get(sessionID, filepath)
yield* read(id, file)
const first = yield* get(id, file)
await FileTime.read(sessionID, filepath)
const second = await FileTime.get(sessionID, filepath)
yield* read(id, file)
const second = yield* get(id, file)
expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime())
},
})
})
}),
),
)
test("isolates reads by directory", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
await using shared = await tmpdir()
const filepath = path.join(shared.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
it.live("isolates reads by directory", () =>
Effect.gen(function* () {
const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
const shared = yield* tmpdirScoped()
const file = path.join(shared, "file.txt")
yield* put(file, "content")
await Instance.provide({
directory: one.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
},
})
await Instance.provide({
directory: two.path,
fn: async () => {
expect(await FileTime.get(sessionID, filepath)).toBeUndefined()
},
})
})
yield* provideInstance(one)(read(id, file))
const result = yield* provideInstance(two)(get(id, file))
expect(result).toBeUndefined()
}),
)
})
describe("assert()", () => {
test("passes when file has not been modified", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await touch(filepath, 1_000)
it.live("passes when file has not been modified", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* touch(file, 1_000)
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
await FileTime.assert(sessionID, filepath)
},
})
})
yield* read(id, file)
yield* check(id, file)
}),
),
)
test("throws when file was not read first", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
it.live("throws when file was not read first", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("You must read file")
},
})
})
const err = yield* fail(check(id, file))
expect(err.message).toContain("You must read file")
}),
),
)
test("throws when file was modified after read", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await touch(filepath, 1_000)
it.live("throws when file was modified after read", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* touch(file, 1_000)
await Instance.provide({
directory: tmp.path,
fn: async () => {
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")
},
})
})
yield* read(id, file)
yield* put(file, "modified content")
yield* touch(file, 2_000)
test("includes timestamps in error message", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await touch(filepath, 1_000)
const err = yield* fail(check(id, file))
expect(err.message).toContain("modified since it was last read")
}),
),
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
await fs.writeFile(filepath, "modified", "utf-8")
await touch(filepath, 2_000)
it.live("includes timestamps in error message", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* touch(file, 1_000)
let error: Error | undefined
try {
await FileTime.assert(sessionID, filepath)
} catch (e) {
error = e as Error
}
expect(error).toBeDefined()
expect(error!.message).toContain("Last modification:")
expect(error!.message).toContain("Last read:")
},
})
})
yield* read(id, file)
yield* put(file, "modified")
yield* touch(file, 2_000)
const err = yield* fail(check(id, file))
expect(err.message).toContain("Last modification:")
expect(err.message).toContain("Last read:")
}),
),
)
})
describe("withLock()", () => {
test("executes function within lock", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
it.live("executes function within lock", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
let hit = false
await Instance.provide({
directory: tmp.path,
fn: async () => {
let executed = false
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")
yield* lock(file, () =>
Effect.sync(() => {
hit = true
return "result"
}),
).rejects.toThrow("Test error")
)
let executed = false
await FileTime.withLock(filepath, async () => {
executed = true
})
expect(executed).toBe(true)
},
})
})
expect(hit).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", () => {
test("read with forward slashes, assert with backslashes", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await touch(filepath, 1_000)
it.live("read with forward slashes, assert with backslashes", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
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({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, forwardSlash)
// assert with the native backslash path should still work
await FileTime.assert(sessionID, filepath)
},
})
})
it.live("read with backslashes, assert with forward slashes", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* touch(file, 1_000)
test("read with backslashes, assert with forward slashes", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await touch(filepath, 1_000)
const forward = file.replaceAll("\\", "/")
yield* read(id, file)
yield* check(id, forward)
}),
),
)
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({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
// assert with forward slashes should still work
await FileTime.assert(sessionID, forwardSlash)
},
})
})
const forward = file.replaceAll("\\", "/")
yield* read(id, forward)
test("get returns timestamp regardless of slash direction", async () => {
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)
const result = yield* get(id, file)
expect(result).toBeInstanceOf(Date)
},
})
})
}),
),
)
test("withLock serializes regardless of slash direction", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
const forwardSlash = filepath.replaceAll("\\", "/")
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live("withLock serializes regardless of slash direction", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
const forward = file.replaceAll("\\", "/")
const order: number[] = []
const hold = gate()
const ready = gate()
const hold = yield* Deferred.make<void>()
const ready = yield* Deferred.make<void>()
const op1 = FileTime.withLock(filepath, async () => {
order.push(1)
ready.open()
await hold.wait
order.push(2)
})
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)
await ready.wait
yield* Deferred.await(ready)
// Use forward-slash variant -- should still serialize against op1
const op2 = FileTime.withLock(forwardSlash, async () => {
order.push(3)
order.push(4)
})
const two = yield* lock(forward, () =>
Effect.sync(() => {
order.push(3)
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])
},
})
})
}),
),
)
})
describe("stat() Filesystem.stat pattern", () => {
test("reads file modification time via Filesystem.stat()", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await touch(filepath, 1_000)
it.live("reads file modification time via Filesystem.stat()", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* touch(file, 1_000)
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
yield* read(id, file)
const stats = Filesystem.stat(filepath)
expect(stats?.mtime).toBeInstanceOf(Date)
expect(stats!.mtime.getTime()).toBeGreaterThan(0)
const stat = Filesystem.stat(file)
expect(stat?.mtime).toBeInstanceOf(Date)
expect(stat!.mtime.getTime()).toBeGreaterThan(0)
await FileTime.assert(sessionID, filepath)
},
})
})
yield* check(id, file)
}),
),
)
test("detects modification via stat mtime", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "original", "utf-8")
await touch(filepath, 1_000)
it.live("detects modification via stat mtime", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "original")
yield* touch(file, 1_000)
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
yield* read(id, file)
const originalStat = Filesystem.stat(filepath)
const first = Filesystem.stat(file)
await fs.writeFile(filepath, "modified", "utf-8")
await touch(filepath, 2_000)
yield* put(file, "modified")
yield* touch(file, 2_000)
const newStat = Filesystem.stat(filepath)
expect(newStat!.mtime.getTime()).toBeGreaterThan(originalStat!.mtime.getTime())
const second = Filesystem.stat(file)
expect(second!.mtime.getTime()).toBeGreaterThan(first!.mtime.getTime())
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow()
},
})
})
yield* fail(check(id, file))
}),
),
)
})
})

View File

@@ -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)
}

View File

@@ -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)
})
})

View File

@@ -1,5 +1,6 @@
import { afterEach, expect, mock, test } from "bun:test"
import { CopilotModels } from "@/plugin/github-copilot/models"
import { CopilotAuthPlugin } from "@/plugin/github-copilot/copilot"
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["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")
})

View File

@@ -13,8 +13,6 @@ const { PluginLoader } = await import("../../src/plugin/loader")
const { readPackageThemes } = await import("../../src/plugin/shared")
const { Instance } = await import("../../src/project/instance")
const { Npm } = await import("../../src/npm")
const { Bus } = await import("../../src/bus")
const { Session } = await import("../../src/session")
afterAll(() => {
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", () => {
test("loads a file:// plugin function export", async () => {
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)
.text()
.then(() => true)
.catch(() => 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 () => {
@@ -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)
.text()
.then(() => true)
.catch(() => 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 () => {
@@ -383,8 +358,7 @@ describe("plugin.loader.shared", () => {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
const errors = await errs(tmp.path)
expect(errors).toHaveLength(0)
await load(tmp.path)
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
} finally {
install.mockRestore()
@@ -436,8 +410,7 @@ describe("plugin.loader.shared", () => {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
const errors = await errs(tmp.path)
expect(errors).toHaveLength(0)
await load(tmp.path)
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
} finally {
install.mockRestore()
@@ -482,14 +455,13 @@ describe("plugin.loader.shared", () => {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
const errors = await errs(tmp.path)
await load(tmp.path)
const called = await Bun.file(tmp.extra.mark)
.text()
.then(() => true)
.catch(() => false)
expect(called).toBe(false)
expect(errors).toHaveLength(0)
} finally {
install.mockRestore()
}
@@ -546,13 +518,12 @@ describe("plugin.loader.shared", () => {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
const errors = await errs(tmp.path)
await load(tmp.path)
const called = await Bun.file(tmp.extra.mark)
.text()
.then(() => true)
.catch(() => false)
expect(called).toBe(false)
expect(errors.some((x) => x.includes("outside plugin directory"))).toBe(true)
} finally {
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({
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"))
try {
const errors = await errs(tmp.path)
expect(errors.some((x) => x.includes("Failed to install plugin broken-plugin@9.9.9") && x.includes("boom"))).toBe(
true,
)
await load(tmp.path)
expect(install).toHaveBeenCalledWith("broken-plugin@9.9.9")
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
} finally {
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({
init: async (dir) => {
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(
path.join(dir, "throws.ts"),
[
@@ -624,51 +614,91 @@ describe("plugin.loader.shared", () => {
"",
].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)
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}: explode`))).toBe(true)
await load(tmp.path)
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
})
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({
init: async (dir) => {
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(
path.join(dir, "invalid.ts"),
["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)
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}`))).toBe(true)
await load(tmp.path)
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
})
test("publishes session.error when plugin import fails", async () => {
test("continues loading plugins when plugin import fails", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
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)
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.missing}`))).toBe(true)
await load(tmp.path)
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
})
test("loads object plugin via plugin.server", async () => {

View File

@@ -3,6 +3,7 @@ import { afterEach, describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { tmpdir } from "../fixture/fixture"
import { AppRuntime } from "../../src/effect/app-runtime"
import { FileWatcher } from "../../src/file/watcher"
import { Instance } from "../../src/project/instance"
import { GlobalBus } from "../../src/bus/global"
@@ -19,7 +20,7 @@ async function withVcs(directory: string, body: () => Promise<void>) {
return Instance.provide({
directory,
fn: async () => {
FileWatcher.init()
void AppRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init()))
Vcs.init()
await Bun.sleep(500)
await body()

View File

@@ -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", () => {
const sessionID = "test-session-123"
@@ -1790,6 +1842,11 @@ describe("ProviderTransform.message - cache control on gateway", () => {
type: "ephemeral",
},
},
alibaba: {
cacheControl: {
type: "ephemeral",
},
},
})
})
@@ -1842,6 +1899,11 @@ describe("ProviderTransform.message - cache control on gateway", () => {
type: "ephemeral",
},
},
alibaba: {
cacheControl: {
type: "ephemeral",
},
},
})
})
})

View File

@@ -4,6 +4,17 @@ import { Instance } from "../../src/project/instance"
import { QuestionID } from "../../src/question/schema"
import { tmpdir } from "../fixture/fixture"
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 () => {
await Instance.disposeAll()
@@ -11,9 +22,9 @@ afterEach(async () => {
/** Reject all pending questions so dangling Deferred fibers don't hang the test. */
async function rejectAll() {
const pending = await Question.list()
const pending = await list()
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({
directory: tmp.path,
fn: async () => {
const promise = Question.ask({
const promise = ask({
sessionID: SessionID.make("ses_test"),
questions: [
{
@@ -58,16 +69,16 @@ test("ask - adds to pending list", async () => {
},
]
const askPromise = Question.ask({
const promise = ask({
sessionID: SessionID.make("ses_test"),
questions,
})
const pending = await Question.list()
const pending = await list()
expect(pending.length).toBe(1)
expect(pending[0].questions).toEqual(questions)
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"),
questions,
})
const pending = await Question.list()
const pending = await list()
const requestID = pending[0].id
await Question.reply({
await reply({
requestID,
answers: [["Option 1"]],
})
const answers = await askPromise
const answers = await promise
expect(answers).toEqual([["Option 1"]])
},
})
@@ -114,7 +125,7 @@ test("reply - removes from pending list", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = Question.ask({
const promise = ask({
sessionID: SessionID.make("ses_test"),
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)
await Question.reply({
await reply({
requestID: pending[0].id,
answers: [["Option 1"]],
})
await askPromise
await promise
const pendingAfter = await Question.list()
expect(pendingAfter.length).toBe(0)
const after = await list()
expect(after.length).toBe(0)
},
})
})
@@ -148,7 +159,7 @@ test("reply - does nothing for unknown requestID", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Question.reply({
await reply({
requestID: QuestionID.make("que_unknown"),
answers: [["Option 1"]],
})
@@ -164,7 +175,7 @@ test("reject - throws RejectedError", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = Question.ask({
const promise = ask({
sessionID: SessionID.make("ses_test"),
questions: [
{
@@ -178,10 +189,10 @@ test("reject - throws RejectedError", async () => {
],
})
const pending = await Question.list()
await Question.reject(pending[0].id)
const pending = await list()
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({
directory: tmp.path,
fn: async () => {
const askPromise = Question.ask({
const promise = ask({
sessionID: SessionID.make("ses_test"),
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)
await Question.reject(pending[0].id)
askPromise.catch(() => {}) // Ignore rejection
await reject(pending[0].id)
promise.catch(() => {}) // Ignore rejection
const pendingAfter = await Question.list()
expect(pendingAfter.length).toBe(0)
const after = await list()
expect(after.length).toBe(0)
},
})
})
@@ -222,7 +233,7 @@ test("reject - does nothing for unknown requestID", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Question.reject(QuestionID.make("que_unknown"))
await reject(QuestionID.make("que_unknown"))
// 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"),
questions,
})
const pending = await Question.list()
const pending = await list()
await Question.reply({
await reply({
requestID: pending[0].id,
answers: [["Build"], ["Dev"]],
})
const answers = await askPromise
const answers = await promise
expect(answers).toEqual([["Build"], ["Dev"]])
},
})
@@ -279,7 +290,7 @@ test("list - returns all pending requests", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const p1 = Question.ask({
const p1 = ask({
sessionID: SessionID.make("ses_test1"),
questions: [
{
@@ -290,7 +301,7 @@ test("list - returns all pending requests", async () => {
],
})
const p2 = Question.ask({
const p2 = ask({
sessionID: SessionID.make("ses_test2"),
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)
await rejectAll()
p1.catch(() => {})
@@ -315,7 +326,7 @@ test("list - returns empty when no pending", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const pending = await Question.list()
const pending = await list()
expect(pending.length).toBe(0)
},
})
@@ -328,7 +339,7 @@ test("questions stay isolated by directory", async () => {
const p1 = Instance.provide({
directory: one.path,
fn: () =>
Question.ask({
ask({
sessionID: SessionID.make("ses_one"),
questions: [
{
@@ -343,7 +354,7 @@ test("questions stay isolated by directory", async () => {
const p2 = Instance.provide({
directory: two.path,
fn: () =>
Question.ask({
ask({
sessionID: SessionID.make("ses_two"),
questions: [
{
@@ -357,11 +368,11 @@ test("questions stay isolated by directory", async () => {
const onePending = await Instance.provide({
directory: one.path,
fn: () => Question.list(),
fn: () => list(),
})
const twoPending = await Instance.provide({
directory: two.path,
fn: () => Question.list(),
fn: () => list(),
})
expect(onePending.length).toBe(1)
@@ -371,11 +382,11 @@ test("questions stay isolated by directory", async () => {
await Instance.provide({
directory: one.path,
fn: () => Question.reject(onePending[0].id),
fn: () => reject(onePending[0].id),
})
await Instance.provide({
directory: two.path,
fn: () => Question.reject(twoPending[0].id),
fn: () => reject(twoPending[0].id),
})
await p1.catch(() => {})
@@ -385,10 +396,10 @@ test("questions stay isolated by directory", async () => {
test("pending question rejects on instance dispose", async () => {
await using tmp = await tmpdir({ git: true })
const ask = Instance.provide({
const pending = Instance.provide({
directory: tmp.path,
fn: () => {
return Question.ask({
return ask({
sessionID: SessionID.make("ses_dispose"),
questions: [
{
@@ -400,7 +411,7 @@ test("pending question rejects on instance dispose", async () => {
})
},
})
const result = ask.then(
const result = pending.then(
() => "resolved" as const,
(err) => err,
)
@@ -408,8 +419,8 @@ test("pending question rejects on instance dispose", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const pending = await Question.list()
expect(pending).toHaveLength(1)
const items = await list()
expect(items).toHaveLength(1)
await Instance.dispose()
},
})
@@ -420,10 +431,10 @@ test("pending question rejects on instance dispose", async () => {
test("pending question rejects on instance reload", async () => {
await using tmp = await tmpdir({ git: true })
const ask = Instance.provide({
const pending = Instance.provide({
directory: tmp.path,
fn: () => {
return Question.ask({
return ask({
sessionID: SessionID.make("ses_reload"),
questions: [
{
@@ -435,7 +446,7 @@ test("pending question rejects on instance reload", async () => {
})
},
})
const result = ask.then(
const result = pending.then(
() => "resolved" as const,
(err) => err,
)
@@ -443,8 +454,8 @@ test("pending question rejects on instance reload", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const pending = await Question.list()
expect(pending).toHaveLength(1)
const items = await list()
expect(items).toHaveLength(1)
await Instance.reload({ directory: tmp.path })
},
})

View File

@@ -43,7 +43,6 @@ describe("project.initGit endpoint", () => {
worktree: tmp.path,
})
expect(reloadSpy).toHaveBeenCalledTimes(1)
expect(reloadSpy.mock.calls[0]?.[0]?.init).toBe(InstanceBootstrap)
expect(seen.some((evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed")).toBe(
true,
)

View File

@@ -1,11 +1,9 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { Effect } from "effect"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
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 { SessionRunState } from "../../src/session/run-state"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
@@ -16,25 +14,6 @@ afterEach(async () => {
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", () => {
test("abort route calls SessionPrompt.cancel", async () => {
await using tmp = await tmpdir({ git: true })
@@ -45,9 +24,7 @@ describe("session action routes", () => {
const cancel = spyOn(SessionPrompt, "cancel").mockResolvedValue()
const app = Server.Default().app
const res = await app.request(`/session/${session.id}/abort`, {
method: "POST",
})
const res = await app.request(`/session/${session.id}/abort`, { method: "POST" })
expect(res.status).toBe(200)
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)
},
})
})
})

View File

@@ -147,7 +147,7 @@ describe("session messages endpoint", () => {
describe("session.prompt_async error handling", () => {
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 end = src.indexOf('"/:sessionID/command"', start)
expect(start).toBeGreaterThan(-1)

View File

@@ -1005,6 +1005,15 @@ describe("session.getUsage", () => {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
})
@@ -1023,7 +1032,15 @@ describe("session.getUsage", () => {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
cachedInputTokens: 200,
inputTokenDetails: {
noCacheTokens: 800,
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
})
@@ -1039,6 +1056,15 @@ describe("session.getUsage", () => {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
metadata: {
anthropic: {
@@ -1059,7 +1085,15 @@ describe("session.getUsage", () => {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
cachedInputTokens: 200,
inputTokenDetails: {
noCacheTokens: 800,
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
metadata: {
anthropic: {},
@@ -1078,7 +1112,15 @@ describe("session.getUsage", () => {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
reasoningTokens: 100,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: 400,
reasoningTokens: 100,
},
},
})
@@ -1104,7 +1146,15 @@ describe("session.getUsage", () => {
inputTokens: 0,
outputTokens: 1_000_000,
totalTokens: 1_000_000,
reasoningTokens: 250_000,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: 750_000,
reasoningTokens: 250_000,
},
},
})
@@ -1121,6 +1171,15 @@ describe("session.getUsage", () => {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
})
@@ -1148,6 +1207,15 @@ describe("session.getUsage", () => {
inputTokens: 1_000_000,
outputTokens: 100_000,
totalTokens: 1_100_000,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
})
@@ -1163,7 +1231,15 @@ describe("session.getUsage", () => {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
cachedInputTokens: 200,
inputTokenDetails: {
noCacheTokens: 800,
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
}
if (npm === "@ai-sdk/amazon-bedrock") {
const result = Session.getUsage({
@@ -1214,7 +1290,15 @@ describe("session.getUsage", () => {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
cachedInputTokens: 200,
inputTokenDetails: {
noCacheTokens: 800,
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
metadata: {
vertex: {

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import path from "path"
import { Effect } from "effect"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instruction } from "../../src/session/instruction"
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 { 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[] {
const sessionID = SessionID.make("session-loaded-1")
const messageID = MessageID.make("message-loaded-1")
@@ -57,17 +61,22 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const system = await Instruction.systemPaths()
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
fn: () =>
run(
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(
[],
path.join(tmp.path, "src", "file.ts"),
MessageID.make("message-test-1"),
)
expect(results).toEqual([])
},
const results = yield* svc.resolve(
[],
path.join(tmp.path, "src", "file.ts"),
MessageID.make("message-test-1"),
)
expect(results).toEqual([])
}),
),
),
})
})
@@ -80,18 +89,23 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const system = await Instruction.systemPaths()
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
fn: () =>
run(
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(
[],
path.join(tmp.path, "subdir", "nested", "file.ts"),
MessageID.make("message-test-2"),
)
expect(results.length).toBe(1)
expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
},
const results = yield* svc.resolve(
[],
path.join(tmp.path, "subdir", "nested", "file.ts"),
MessageID.make("message-test-2"),
)
expect(results.length).toBe(1)
expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
}),
),
),
})
})
@@ -104,14 +118,19 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const filepath = path.join(tmp.path, "subdir", "AGENTS.md")
const system = await Instruction.systemPaths()
expect(system.has(filepath)).toBe(false)
fn: () =>
run(
Instruction.Service.use((svc) =>
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"))
expect(results).toEqual([])
},
const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3"))
expect(results).toEqual([])
}),
),
),
})
})
@@ -124,17 +143,22 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-1")
fn: () =>
run(
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 second = await Instruction.resolve([], filepath, id)
const first = yield* svc.resolve([], filepath, id)
const second = yield* svc.resolve([], filepath, id)
expect(first).toHaveLength(1)
expect(first[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
expect(second).toEqual([])
},
expect(first).toHaveLength(1)
expect(first[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
expect(second).toEqual([])
}),
),
),
})
})
@@ -147,18 +171,23 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-2")
fn: () =>
run(
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)
await Instruction.clear(id)
const second = await Instruction.resolve([], filepath, id)
const first = yield* svc.resolve([], filepath, id)
yield* svc.clear(id)
const second = yield* svc.resolve([], filepath, id)
expect(first).toHaveLength(1)
expect(second).toHaveLength(1)
expect(second[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
},
expect(first).toHaveLength(1)
expect(second).toHaveLength(1)
expect(second[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
}),
),
),
})
})
@@ -171,15 +200,19 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
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")
fn: () =>
run(
Instruction.Service.use((svc) =>
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)
expect(results).toEqual([])
},
const results = yield* svc.resolve(loaded(agents), filepath, id)
expect(results).toEqual([])
}),
),
),
})
})
@@ -221,11 +254,16 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
try {
await Instance.provide({
directory: projectTmp.path,
fn: async () => {
const paths = await Instruction.systemPaths()
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true)
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false)
},
fn: () =>
run(
Instruction.Service.use((svc) =>
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 {
;(Global.Path as { config: string }).config = originalGlobalConfig
@@ -248,11 +286,16 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
try {
await Instance.provide({
directory: projectTmp.path,
fn: async () => {
const paths = await Instruction.systemPaths()
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false)
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
},
fn: () =>
run(
Instruction.Service.use((svc) =>
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 {
;(Global.Path as { config: string }).config = originalGlobalConfig
@@ -274,10 +317,15 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
try {
await Instance.provide({
directory: projectTmp.path,
fn: async () => {
const paths = await Instruction.systemPaths()
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
},
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
}),
),
),
})
} finally {
;(Global.Path as { config: string }).config = originalGlobalConfig

View File

@@ -511,6 +511,49 @@ test("circular symlinks", async () => {
})
})
test("source project gitignore is respected - ignored files are not snapshotted", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
// Create gitignore BEFORE any tracking
await Filesystem.write(`${dir}/.gitignore`, "*.ignored\nbuild/\nnode_modules/\n")
await Filesystem.write(`${dir}/tracked.txt`, "tracked content")
await Filesystem.write(`${dir}/ignored.ignored`, "ignored content")
await $`mkdir -p ${dir}/build`.quiet()
await Filesystem.write(`${dir}/build/output.js`, "build output")
await Filesystem.write(`${dir}/normal.js`, "normal js")
await $`git add .`.cwd(dir).quiet()
await $`git commit -m init`.cwd(dir).quiet()
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Modify tracked files and create new ones - some ignored, some not
await Filesystem.write(`${tmp.path}/tracked.txt`, "modified tracked")
await Filesystem.write(`${tmp.path}/new.ignored`, "new ignored")
await Filesystem.write(`${tmp.path}/new-tracked.txt`, "new tracked")
await Filesystem.write(`${tmp.path}/build/new-build.js`, "new build file")
const patch = await Snapshot.patch(before!)
// Modified and new tracked files should be in snapshot
expect(patch.files).toContain(fwd(tmp.path, "new-tracked.txt"))
expect(patch.files).toContain(fwd(tmp.path, "tracked.txt"))
// Ignored files should NOT be in snapshot
expect(patch.files).not.toContain(fwd(tmp.path, "new.ignored"))
expect(patch.files).not.toContain(fwd(tmp.path, "ignored.ignored"))
expect(patch.files).not.toContain(fwd(tmp.path, "build/output.js"))
expect(patch.files).not.toContain(fwd(tmp.path, "build/new-build.js"))
},
})
})
test("gitignore changes", async () => {
await using tmp = await bootstrap()
await Instance.provide({
@@ -535,6 +578,75 @@ test("gitignore changes", async () => {
})
})
test("files tracked in snapshot but now gitignored are filtered out", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.path,
fn: async () => {
// First, create a file and snapshot it
await Filesystem.write(`${tmp.path}/later-ignored.txt`, "initial content")
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Modify the file (so it appears in diff-files)
await Filesystem.write(`${tmp.path}/later-ignored.txt`, "modified content")
// Now add gitignore that would exclude this file
await Filesystem.write(`${tmp.path}/.gitignore`, "later-ignored.txt\n")
// Also create another tracked file
await Filesystem.write(`${tmp.path}/still-tracked.txt`, "new tracked file")
const patch = await Snapshot.patch(before!)
// The file that is now gitignored should NOT appear, even though it was
// previously tracked and modified
expect(patch.files).not.toContain(fwd(tmp.path, "later-ignored.txt"))
// The gitignore file itself should appear
expect(patch.files).toContain(fwd(tmp.path, ".gitignore"))
// Other tracked files should appear
expect(patch.files).toContain(fwd(tmp.path, "still-tracked.txt"))
},
})
})
test("gitignore updated between track calls filters from diff", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.path,
fn: async () => {
// a.txt is already committed from bootstrap - track it in snapshot
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Modify a.txt (so it appears in diff-files)
await Filesystem.write(`${tmp.path}/a.txt`, "modified content")
// Now add gitignore that would exclude a.txt
await Filesystem.write(`${tmp.path}/.gitignore`, "a.txt\n")
// Also modify b.txt which is not gitignored
await Filesystem.write(`${tmp.path}/b.txt`, "also modified")
// Second track - should not include a.txt even though it changed
const after = await Snapshot.track()
expect(after).toBeTruthy()
// Verify a.txt is NOT in the diff between snapshots
const diffs = await Snapshot.diffFull(before!, after!)
expect(diffs.some((x) => x.file === "a.txt")).toBe(false)
// But .gitignore should be in the diff
expect(diffs.some((x) => x.file === ".gitignore")).toBe(true)
// b.txt should be in the diff (not gitignored)
expect(diffs.some((x) => x.file === "b.txt")).toBe(true)
},
})
})
test("git info exclude changes", async () => {
await using tmp = await bootstrap()
await Instance.provide({

View File

@@ -7,12 +7,21 @@ import { Instance } from "../../src/project/instance"
import { LSP } from "../../src/lsp"
import { AppFileSystem } from "../../src/filesystem"
import { Format } from "../../src/format"
import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { Truncate } from "../../src/tool/truncate"
import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
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 = {

View File

@@ -8,6 +8,7 @@ import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import type { Permission } from "../../src/permission"
import { Agent } from "../../src/agent/agent"
import { Truncate } from "../../src/tool/truncate"
import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
@@ -15,7 +16,13 @@ import { AppFileSystem } from "../../src/filesystem"
import { Plugin } from "../../src/plugin"
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() {

View File

@@ -9,8 +9,10 @@ import { FileTime } from "../../src/file/time"
import { LSP } from "../../src/lsp"
import { AppFileSystem } from "../../src/filesystem"
import { Format } from "../../src/format"
import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { Truncate } from "../../src/tool/truncate"
import { SessionID, MessageID } from "../../src/session/schema"
const ctx = {
@@ -34,7 +36,15 @@ async function touch(file: string, time: number) {
}
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 () => {
@@ -55,6 +65,18 @@ const readFileTime = (sessionID: SessionID, filepath: string) =>
const subscribeBus = <D extends BusEvent.Definition>(def: D, callback: () => unknown) =>
runtime.runPromise(Bus.Service.use((bus) => bus.subscribeCallback(def, callback)))
async function onceBus<D extends BusEvent.Definition>(def: D) {
const result = Promise.withResolvers<void>()
const unsub = await subscribeBus(def, () => {
unsub()
result.resolve()
})
return {
wait: result.promise,
unsub,
}
}
describe("tool.edit", () => {
describe("creating new files", () => {
test("creates new file when oldString is empty", async () => {
@@ -118,23 +140,25 @@ describe("tool.edit", () => {
fn: async () => {
const { FileWatcher } = await import("../../src/file/watcher")
const events: string[] = []
const unsubUpdated = await subscribeBus(FileWatcher.Event.Updated, () => events.push("updated"))
const updated = await onceBus(FileWatcher.Event.Updated)
const edit = await resolve()
await Effect.runPromise(
edit.execute(
{
filePath: filepath,
oldString: "",
newString: "content",
},
ctx,
),
)
try {
const edit = await resolve()
await Effect.runPromise(
edit.execute(
{
filePath: filepath,
oldString: "",
newString: "content",
},
ctx,
),
)
expect(events).toContain("updated")
unsubUpdated()
await updated.wait
} finally {
updated.unsub()
}
},
})
})
@@ -349,23 +373,25 @@ describe("tool.edit", () => {
const { FileWatcher } = await import("../../src/file/watcher")
const events: string[] = []
const unsubUpdated = await subscribeBus(FileWatcher.Event.Updated, () => events.push("updated"))
const updated = await onceBus(FileWatcher.Event.Updated)
const edit = await resolve()
await Effect.runPromise(
edit.execute(
{
filePath: filepath,
oldString: "original",
newString: "modified",
},
ctx,
),
)
try {
const edit = await resolve()
await Effect.runPromise(
edit.execute(
{
filePath: filepath,
oldString: "original",
newString: "modified",
},
ctx,
),
)
expect(events).toContain("updated")
unsubUpdated()
await updated.wait
} finally {
updated.unsub()
}
},
})
})

View File

@@ -1,17 +1,25 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import path from "path"
import { Effect, Layer, ManagedRuntime } from "effect"
import { Effect, Layer } from "effect"
import { GrepTool } from "../../src/tool/grep"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { provideInstance, provideTmpdirInstance } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Truncate } from "../../src/tool/truncate"
import { Agent } from "../../src/agent/agent"
import { Ripgrep } from "../../src/file/ripgrep"
import { AppFileSystem } from "../../src/filesystem"
import { testEffect } from "../lib/effect"
const runtime = ManagedRuntime.make(Layer.mergeAll(CrossSpawnSpawner.defaultLayer))
function initGrep() {
return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => info.init())))
}
const it = testEffect(
Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer,
Ripgrep.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
)
const ctx = {
sessionID: SessionID.make("ses_test"),
@@ -27,99 +35,59 @@ const ctx = {
const projectRoot = path.join(__dirname, "../..")
describe("tool.grep", () => {
test("basic search", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const grep = await initGrep()
const result = await Effect.runPromise(
grep.execute(
{
pattern: "export",
path: path.join(projectRoot, "src/tool"),
include: "*.ts",
},
ctx,
),
)
expect(result.metadata.matches).toBeGreaterThan(0)
expect(result.output).toContain("Found")
},
})
})
it.live("basic search", () =>
Effect.gen(function* () {
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* provideInstance(projectRoot)(
grep.execute(
{
pattern: "export",
path: path.join(projectRoot, "src/tool"),
include: "*.ts",
},
ctx,
),
)
expect(result.metadata.matches).toBeGreaterThan(0)
expect(result.output).toContain("Found")
}),
)
test("no matches returns correct output", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), "hello world")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const grep = await initGrep()
const result = await Effect.runPromise(
grep.execute(
{
pattern: "xyznonexistentpatternxyz123",
path: tmp.path,
},
ctx,
),
it.live("no matches returns correct output", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "hello world"))
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* grep.execute(
{
pattern: "xyznonexistentpatternxyz123",
path: dir,
},
ctx,
)
expect(result.metadata.matches).toBe(0)
expect(result.output).toBe("No files found")
},
})
})
}),
),
)
test("handles CRLF line endings in output", async () => {
// This test verifies the regex split handles both \n and \r\n
await using tmp = await tmpdir({
init: async (dir) => {
// Create a test file with content
await Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const grep = await initGrep()
const result = await Effect.runPromise(
grep.execute(
{
pattern: "line",
path: tmp.path,
},
ctx,
),
it.live("finds matches in tmp instance", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3"))
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* grep.execute(
{
pattern: "line",
path: dir,
},
ctx,
)
expect(result.metadata.matches).toBeGreaterThan(0)
},
})
})
})
describe("CRLF regex handling", () => {
test("regex correctly splits Unix line endings", () => {
const unixOutput = "file1.txt|1|content1\nfile2.txt|2|content2\nfile3.txt|3|content3"
const lines = unixOutput.trim().split(/\r?\n/)
expect(lines.length).toBe(3)
expect(lines[0]).toBe("file1.txt|1|content1")
expect(lines[2]).toBe("file3.txt|3|content3")
})
test("regex correctly splits Windows CRLF line endings", () => {
const windowsOutput = "file1.txt|1|content1\r\nfile2.txt|2|content2\r\nfile3.txt|3|content3"
const lines = windowsOutput.trim().split(/\r?\n/)
expect(lines.length).toBe(3)
expect(lines[0]).toBe("file1.txt|1|content1")
expect(lines[2]).toBe("file3.txt|3|content3")
})
test("regex handles mixed line endings", () => {
const mixedOutput = "file1.txt|1|content1\nfile2.txt|2|content2\r\nfile3.txt|3|content3"
const lines = mixedOutput.trim().split(/\r?\n/)
expect(lines.length).toBe(3)
})
}),
),
)
})

View File

@@ -4,7 +4,9 @@ import { Tool } from "../../src/tool/tool"
import { QuestionTool } from "../../src/tool/question"
import { Question } from "../../src/question"
import { SessionID, MessageID } from "../../src/session/schema"
import { Agent } from "../../src/agent/agent"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Truncate } from "../../src/tool/truncate"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -19,7 +21,9 @@ const ctx = {
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) {
for (;;) {

View File

@@ -11,6 +11,7 @@ import { Instance } from "../../src/project/instance"
import { SessionID, MessageID } from "../../src/session/schema"
import { Instruction } from "../../src/session/instruction"
import { ReadTool } from "../../src/tool/read"
import { Truncate } from "../../src/tool/truncate"
import { Tool } from "../../src/tool/tool"
import { Filesystem } from "../../src/util/filesystem"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
@@ -41,6 +42,7 @@ const it = testEffect(
FileTime.defaultLayer,
Instruction.defaultLayer,
LSP.defaultLayer,
Truncate.defaultLayer,
),
)

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