Compare commits

..
Author SHA1 Message Date
Kit Langton 3f93669f4c refactor(session): replace Effect.promise with Effect.sync for sync MessageV2.stream calls
MessageV2.stream is a synchronous generator, so wrapping it in
Effect.promise with `for await` is an unnecessary async roundtrip.
Use Effect.sync with a plain `for` loop instead.
2026-04-10 23:33:46 -04:00
113 changed files with 61304 additions and 67161 deletions
-1
View File
@@ -26,7 +26,6 @@ kommander
r44vc0rp
rekram1-node
-robinmordasiewicz
simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr
-toastythebot
+2 -1
View File
@@ -114,7 +114,7 @@ jobs:
- build-cli
- version
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode'
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -591,6 +591,7 @@ 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
+18 -22
View File
@@ -319,7 +319,7 @@
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/amazon-bedrock": "4.0.93",
"@ai-sdk/amazon-bedrock": "4.0.83",
"@ai-sdk/anthropic": "3.0.67",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
@@ -331,7 +331,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.41",
"@ai-sdk/openai-compatible": "2.0.37",
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23",
@@ -341,6 +341,7 @@
"@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",
@@ -356,7 +357,7 @@
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.5.1",
"@openrouter/ai-sdk-provider": "2.4.2",
"@opentui/core": "0.1.97",
"@opentui/solid": "0.1.97",
"@parcel/watcher": "2.5.1",
@@ -449,7 +450,6 @@
"version": "1.4.3",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
@@ -662,7 +662,7 @@
"@types/node": "22.13.9",
"@types/semver": "7.7.1",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"ai": "6.0.158",
"ai": "6.0.149",
"cross-spawn": "7.0.6",
"diff": "8.0.2",
"dompurify": "3.3.1",
@@ -707,7 +707,7 @@
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
"@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/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/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=="],
@@ -1161,6 +1161,8 @@
"@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=="],
@@ -1537,7 +1539,7 @@
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
"@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=="],
"@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=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -2383,7 +2385,7 @@
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"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": ["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-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=="],
@@ -5003,11 +5005,7 @@
"@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
"@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/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/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=="],
@@ -5283,6 +5281,10 @@
"@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=="],
@@ -5551,9 +5553,7 @@
"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.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/@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-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=="],
@@ -5769,8 +5769,6 @@
"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=="],
@@ -5953,6 +5951,8 @@
"@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=="],
@@ -6449,8 +6449,6 @@
"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=="],
@@ -6823,8 +6821,6 @@
"@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=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-fNRQYkucjXr1D61HJRScJpDa6+oBdyhgTBxCu+PE2kQ=",
"aarch64-linux": "sha256-V8J6kn2nSdXrplyqi6aIqNlHcVjSxvye+yC/YFO7PF4=",
"aarch64-darwin": "sha256-6cLmUJVUycGALCmslXuloVGBSlFOSHRjsWjx7KOW8rg=",
"x86_64-darwin": "sha256-kcOSO3NFIJh79ylLotG41ovWLQfH5kh1WYFghUu+4HE="
"x86_64-linux": "sha256-285KZ7rZLRoc6XqCZRHc25NE+mmpGh/BVeMpv8aPQtQ=",
"aarch64-linux": "sha256-qIwmY4TP4CI7R7G6A5OMYRrorVNXjkg25tTtVpIHm2o=",
"aarch64-darwin": "sha256-RwvnZQhdYZ0u7h7evyfxuPLHHX9eO/jXTAxIFc8B+IE=",
"x86_64-darwin": "sha256-vVj40al+TEeMpbe5XG2GmJEpN+eQAvtr9W0T98l5PBE="
}
}
+1 -1
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.158",
"ai": "6.0.149",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -316,8 +316,7 @@
/* Download Hero Section */
[data-component="download-hero"] {
/* display: grid; */
display: none;
display: grid;
grid-template-columns: 260px 1fr;
gap: 4rem;
padding-bottom: 2rem;
+4 -8
View File
@@ -39,11 +39,6 @@
"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": {
@@ -83,7 +78,7 @@
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/amazon-bedrock": "4.0.93",
"@ai-sdk/amazon-bedrock": "4.0.83",
"@ai-sdk/anthropic": "3.0.67",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
@@ -95,7 +90,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.41",
"@ai-sdk/openai-compatible": "2.0.37",
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23",
@@ -105,6 +100,7 @@
"@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",
@@ -120,7 +116,7 @@
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.5.1",
"@openrouter/ai-sdk-provider": "2.4.2",
"@opentui/core": "0.1.97",
"@opentui/solid": "0.1.97",
"@parcel/watcher": "2.5.1",
+8 -54
View File
@@ -217,37 +217,36 @@ 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`
- [ ] `SessionTodo``session/todo.ts`
- [ ] `SyncEvent``sync/index.ts`
- [ ] `Workspace``control-plane/workspace.ts`
## Tool interface → Effect
`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:
Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `init` and `execute` return `Effect` instead of `Promise`. This lets tool implementations compose natively with the Effect pipeline rather than being wrapped in `Effect.promise()` at the call site. Requires:
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
1. Migrate each tool to return Effects
2. Update `Tool.define()` factory to work with Effects
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing
### Tool migration details
With `Tool.Info.init()` now effectful, use this transitional pattern for migrated tools that still need Promise-based boundaries internally:
Until the tool interface itself returns `Effect`, use this transitional pattern for migrated tools:
- `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()`.
- Keep the bridge at the Promise boundary only. Prefer a single `Effect.runPromise(...)` in the temporary `async execute(...)` implementation, and move the inner logic into `Effect.fn(...)` helpers instead of scattering `runPromise` islands through the tool body.
- 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()`.
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* Effect.promise(() => 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.
@@ -339,49 +338,4 @@ For each service, the migration is roughly:
- `SessionStatus` — migrated 2026-04-11. Replaced the last route and retry-policy callers with `AppRuntime.runPromise(SessionStatus.Service.use(...))` and removed the `makeRuntime(...)` facade.
- `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/routes/session.ts` converted; facade removed.
- `Account` — migrated 2026-04-11. Callers in `server/routes/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileTime` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
- `Question` — migrated 2026-04-11. Callers in `server/routes/question.ts` and test converted; facade removed.
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
## Route handler effectification
Route handlers should wrap their entire body in a single `AppRuntime.runPromise(Effect.gen(...))` call, yielding services from context rather than calling facades one-by-one. This eliminates multiple `runPromise` round-trips and lets handlers compose naturally.
```ts
// Before — one facade call per service
;async (c) => {
await SessionRunState.assertNotBusy(id)
await Session.removeMessage({ sessionID: id, messageID })
return c.json(true)
}
// After — one Effect.gen, yield services from context
;async (c) => {
await AppRuntime.runPromise(
Effect.gen(function* () {
const state = yield* SessionRunState.Service
const session = yield* Session.Service
yield* state.assertNotBusy(id)
yield* session.removeMessage({ sessionID: id, messageID })
}),
)
return c.json(true)
}
```
When migrating, always use `{ concurrency: "unbounded" }` with `Effect.all` — route handlers should run independent service calls in parallel, not sequentially.
Route files to convert (each handler that calls facades should be wrapped):
- [ ] `server/routes/session.ts` — heaviest; uses Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, SessionRunState, Agent, Permission, Bus
- [ ] `server/routes/global.ts` — uses Config, Project, Provider, Vcs, Snapshot, Agent
- [ ] `server/routes/provider.ts` — uses Provider, Auth, Config
- [ ] `server/routes/question.ts` — uses Question
- [ ] `server/routes/pty.ts` — uses Pty
- [ ] `server/routes/experimental.ts` — uses Account, ToolRegistry, Agent, MCP, Config
+15
View File
@@ -7,6 +7,7 @@ import {
HttpClientResponse,
} from "effect/unstable/http"
import { makeRuntime } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import { normalizeServerUrl } from "./url"
@@ -453,4 +454,18 @@ export namespace Account {
)
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
export const { runPromise } = makeRuntime(Service, defaultLayer)
export async function active(): Promise<Info | undefined> {
return Option.getOrUndefined(await runPromise((service) => service.active()))
}
export async function orgsByAccount(): Promise<readonly AccountOrgs[]> {
return runPromise((service) => service.orgsByAccount())
}
export async function switchOrg(accountID: AccountID, orgID: OrgID) {
return runPromise((service) => service.use(accountID, Option.some(orgID)))
}
}
+7 -5
View File
@@ -398,11 +398,13 @@ export namespace Agent {
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Provider.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Provider.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
+5 -6
View File
@@ -3,7 +3,6 @@ import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui"
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
import { type AccountError } from "@/account/schema"
import { AppRuntime } from "@/effect/app-runtime"
import * as Prompt from "../effect/prompt"
import open from "open"
@@ -183,7 +182,7 @@ export const LoginCommand = cmd({
}),
async handler(args) {
UI.empty()
await AppRuntime.runPromise(loginEffect(args.url))
await Account.runPromise((_svc) => loginEffect(args.url))
},
})
@@ -197,7 +196,7 @@ export const LogoutCommand = cmd({
}),
async handler(args) {
UI.empty()
await AppRuntime.runPromise(logoutEffect(args.email))
await Account.runPromise((_svc) => logoutEffect(args.email))
},
})
@@ -206,7 +205,7 @@ export const SwitchCommand = cmd({
describe: false,
async handler() {
UI.empty()
await AppRuntime.runPromise(switchEffect())
await Account.runPromise((_svc) => switchEffect())
},
})
@@ -215,7 +214,7 @@ export const OrgsCommand = cmd({
describe: false,
async handler() {
UI.empty()
await AppRuntime.runPromise(orgsEffect())
await Account.runPromise((_svc) => orgsEffect())
},
})
@@ -224,7 +223,7 @@ export const OpenCommand = cmd({
describe: false,
async handler() {
UI.empty()
await AppRuntime.runPromise(openEffect())
await Account.runPromise((_svc) => openEffect())
},
})
+1 -1
View File
@@ -158,7 +158,7 @@ async function createToolContext(agent: Agent.Info) {
agent: agent.name,
abort: new AbortController().signal,
messages: [],
metadata: () => Effect.void,
metadata: () => {},
ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
return Effect.sync(() => {
for (const pattern of req.patterns) {
+4 -8
View File
@@ -29,7 +29,6 @@ import { Provider } from "../../provider/provider"
import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "@/session/prompt"
import { AppRuntime } from "@/effect/app-runtime"
import { Git } from "@/git"
import { setTimeout as sleep } from "node:timers/promises"
import { Process } from "@/util/process"
@@ -259,9 +258,7 @@ export const GithubInstallCommand = cmd({
}
// Get repo info
const info = await AppRuntime.runPromise(
Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })),
).then((x) => x.text().trim())
const info = (await Git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
const parsed = parseGitHubRemote(info)
if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
@@ -500,21 +497,20 @@ export const GithubRunCommand = cmd({
: "issue"
: undefined
const gitText = async (args: string[]) => {
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
const result = await Git.run(args, { cwd: Instance.worktree })
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result.text().trim()
}
const gitRun = async (args: string[]) => {
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
const result = await Git.run(args, { cwd: Instance.worktree })
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result
}
const gitStatus = (args: string[]) =>
AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
const gitStatus = (args: string[]) => Git.run(args, { cwd: Instance.worktree })
const commitChanges = async (summary: string, actor?: string) => {
const args = ["commit", "-m", summary]
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
+7 -18
View File
@@ -1,6 +1,5 @@
import { UI } from "../ui"
import { cmd } from "./cmd"
import { AppRuntime } from "@/effect/app-runtime"
import { Git } from "@/git"
import { Instance } from "@/project/instance"
import { Process } from "@/util/process"
@@ -68,29 +67,19 @@ export const PrCommand = cmd({
const remoteName = forkOwner
// Check if remote already exists
const remotes = await AppRuntime.runPromise(
Git.Service.use((git) => git.run(["remote"], { cwd: Instance.worktree })),
).then((x) => x.text().trim())
const remotes = (await Git.run(["remote"], { cwd: Instance.worktree })).text().trim()
if (!remotes.split("\n").includes(remoteName)) {
await AppRuntime.runPromise(
Git.Service.use((git) =>
git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
cwd: Instance.worktree,
}),
),
)
await Git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
cwd: Instance.worktree,
})
UI.println(`Added fork remote: ${remoteName}`)
}
// Set upstream to the fork so pushes go there
const headRefName = prInfo.headRefName
await AppRuntime.runPromise(
Git.Service.use((git) =>
git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
cwd: Instance.worktree,
}),
),
)
await Git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
cwd: Instance.worktree,
})
}
// Check for opencode session link in PR body
@@ -589,13 +589,6 @@ 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
@@ -1001,11 +994,7 @@ export function Prompt(props: PromptProps) {
input.cursorOffset = input.plainText.length
}
}}
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)
}}
onSubmit={submit}
onPaste={async (event: PasteEvent) => {
if (props.disabled) {
event.preventDefault()
+2 -8
View File
@@ -137,18 +137,12 @@ export const TuiThreadCommand = cmd({
),
})
worker.onerror = (e) => {
Log.Default.error("thread error", {
message: e.message,
filename: e.filename,
lineno: e.lineno,
colno: e.colno,
error: e.error,
})
Log.Default.error(e)
}
const client = Rpc.client<typeof rpc>(worker)
const error = (e: unknown) => {
Log.Default.error("process error", { error: errorMessage(e) })
Log.Default.error(e)
}
const reload = () => {
client.call("reload", undefined).catch((err) => {
+1 -2
View File
@@ -1,7 +1,6 @@
import type { Argv } from "yargs"
import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "../../installation"
import { Global } from "../../global"
import fs from "fs/promises"
@@ -58,7 +57,7 @@ export const UninstallCommand = {
UI.empty()
prompts.intro("Uninstall OpenCode")
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
const method = await Installation.method()
prompts.log.info(`Installation method: ${method}`)
const targets = await collectRemovalTargets(args, method)
+3 -8
View File
@@ -1,7 +1,6 @@
import type { Argv } from "yargs"
import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "../../installation"
export const UpgradeCommand = {
@@ -25,7 +24,7 @@ export const UpgradeCommand = {
UI.println(UI.logo(" "))
UI.empty()
prompts.intro("Upgrade")
const detectedMethod = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
const detectedMethod = await Installation.method()
const method = (args.method as Installation.Method) ?? detectedMethod
if (method === "unknown") {
prompts.log.error(`opencode is installed to ${process.execPath} and may be managed by a package manager`)
@@ -43,9 +42,7 @@ export const UpgradeCommand = {
}
}
prompts.log.info("Using method: " + method)
const target = args.target
? args.target.replace(/^v/, "")
: await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest()))
const target = args.target ? args.target.replace(/^v/, "") : await Installation.latest()
if (Installation.VERSION === target) {
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
@@ -56,9 +53,7 @@ export const UpgradeCommand = {
prompts.log.info(`From ${Installation.VERSION}${target}`)
const spinner = prompts.spinner()
spinner.start("Upgrading...")
const err = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, target))).catch(
(err) => err,
)
const err = await Installation.upgrade(method, target).catch((err) => err)
if (err) {
spinner.stop("Upgrade failed", 1)
if (err instanceof Installation.UpgradeFailedError) {
+3 -4
View File
@@ -1,13 +1,12 @@
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import { AppRuntime } from "@/effect/app-runtime"
import { Flag } from "@/flag/flag"
import { Installation } from "@/installation"
export async function upgrade() {
const config = await Config.getGlobal()
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {})
const method = await Installation.method()
const latest = await Installation.latest(method).catch(() => {})
if (!latest) return
if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) {
@@ -26,7 +25,7 @@ export async function upgrade() {
}
if (method === "unknown") return
await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, latest)))
await Installation.upgrade(method, latest)
.then(() => Bus.publish(Installation.Event.Updated, { version: latest }))
.catch(() => {})
}
+7
View File
@@ -1,5 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import type { InstanceContext } from "@/project/instance"
import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, Context } from "effect"
@@ -188,4 +189,10 @@ export namespace Command {
Layer.provide(MCP.defaultLayer),
Layer.provide(Skill.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function list() {
return runPromise((svc) => svc.list())
}
}
+1
View File
@@ -4,6 +4,7 @@ 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"
@@ -1,10 +0,0 @@
import { Layer, ManagedRuntime } from "effect"
import { memoMap } from "./run-service"
import { FileWatcher } from "@/file/watcher"
import { Format } from "@/format"
import { ShareNext } from "@/share/share-next"
export const BootstrapLayer = Layer.mergeAll(Format.defaultLayer, ShareNext.defaultLayer, FileWatcher.defaultLayer)
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
+19
View File
@@ -1,5 +1,6 @@
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"
@@ -111,4 +112,22 @@ 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)))
}
}
+7
View File
@@ -8,6 +8,7 @@ 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"
@@ -160,4 +161,10 @@ 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())
}
}
+15
View File
@@ -2,6 +2,7 @@ import { Effect, Layer, Context } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import path from "path"
import { mergeDeep } from "remeda"
import z from "zod"
@@ -192,4 +193,18 @@ export namespace Format {
Layer.provide(Config.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function init() {
return runPromise((s) => s.init())
}
export async function status() {
return runPromise((s) => s.status())
}
export async function file(filepath: string) {
return runPromise((s) => s.file(filepath))
}
}
+11
View File
@@ -1,6 +1,7 @@
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Effect, Layer, Context, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { makeRuntime } from "@/effect/run-service"
export namespace Git {
const cfg = [
@@ -257,4 +258,14 @@ export namespace Git {
)
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function run(args: string[], opts: Options) {
return runPromise((git) => git.run(args, opts))
}
export async function defaultBranch(cwd: string) {
return runPromise((git) => git.defaultBranch(cwd))
}
}
@@ -1,6 +1,7 @@
import { Effect, Layer, Schema, Context, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { makeRuntime } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path"
@@ -337,4 +338,18 @@ export namespace Installation {
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function method(): Promise<Method> {
return runPromise((svc) => svc.method())
}
export async function latest(installMethod?: Method): Promise<string> {
return runPromise((svc) => svc.latest(installMethod))
}
export async function upgrade(m: Method, target: string): Promise<void> {
return runPromise((svc) => svc.upgrade(m, target))
}
}
@@ -5,7 +5,6 @@ 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" })
@@ -28,27 +27,11 @@ function base(enterpriseUrl?: string) {
return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com"
}
// 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 {
function fix(model: Model): Model {
return {
...model,
api: {
...model.api,
url,
npm: "@ai-sdk/github-copilot",
},
}
@@ -61,23 +44,19 @@ 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, base())]))
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model)]))
}
const auth = ctx.auth
return CopilotModels.get(
base(auth.enterpriseUrl),
base(ctx.auth.enterpriseUrl),
{
Authorization: `Bearer ${auth.refresh}`,
Authorization: `Bearer ${ctx.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, base(auth.enterpriseUrl))]),
)
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model)]))
})
},
},
@@ -87,7 +66,10 @@ 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()
@@ -106,7 +88,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" || imgMsg(last),
isAgent: last?.role !== "user",
}
}
@@ -118,7 +100,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" || imgMsg(last),
isAgent: last?.role !== "user",
}
}
@@ -140,7 +122,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
part.content.some((nested: any) => nested?.type === "image")),
),
),
isAgent: !(last?.role === "user" && hasNonToolCalls) || imgMsg(last),
isAgent: !(last?.role === "user" && hasNonToolCalls),
}
}
} catch {}
@@ -52,15 +52,13 @@ 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: isMsgApi ? `${url}/v1` : url,
npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot",
url,
npm: "@ai-sdk/github-copilot",
},
// API response wins
status: "active",
+8 -10
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) => (await Server.Default()).app.fetch(...args),
fetch: async (...args) => Server.Default().app.fetch(...args),
})
const cfg = yield* config.get()
const input: PluginInput = {
@@ -210,15 +210,13 @@ export namespace Plugin {
return message
},
}).pipe(
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
}),
Effect.catch((message) =>
bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${load.spec}: ${message}`,
}).toObject(),
}),
),
)
}
+5 -5
View File
@@ -2,6 +2,7 @@ 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"
@@ -9,18 +10,17 @@ 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 { AppRuntime } from "@/effect/app-runtime"
import { ShareNext } from "@/share/share-next"
export async function InstanceBootstrap() {
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()))
void AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.init()))
Format.init()
await LSP.init()
File.init()
void BootstrapRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init()))
FileWatcher.init()
Vcs.init()
Snapshot.init()
+19
View File
@@ -2,6 +2,7 @@ import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
import { NamedError } from "@opencode-ai/util/error"
import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Plugin } from "../plugin"
import { ProviderID } from "./schema"
import { Array as Arr, Effect, Layer, Record, Result, Context } from "effect"
@@ -231,4 +232,22 @@ export namespace ProviderAuth {
export const defaultLayer = Layer.suspend(() =>
layer.pipe(Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer)),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function methods() {
return runPromise((svc) => svc.methods())
}
export async function authorize(input: {
providerID: ProviderID
method: number
inputs?: Record<string, string>
}): Promise<Authorization | undefined> {
return runPromise((svc) => svc.authorize(input))
}
export async function callback(input: { providerID: ProviderID; method: number; code?: string }) {
return runPromise((svc) => svc.callback(input))
}
}
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -774,10 +774,7 @@ export namespace ProviderTransform {
result["chat_template_args"] = { enable_thinking: true }
}
if (
["zai", "zhipuai"].some((id) => input.model.providerID.includes(id)) &&
input.model.api.npm === "@ai-sdk/openai-compatible"
) {
if (["zai", "zhipuai"].includes(input.model.providerID) && input.model.api.npm === "@ai-sdk/openai-compatible") {
result["thinking"] = {
type: "enabled",
clear_thinking: false,
+23
View File
@@ -2,6 +2,7 @@ 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"
@@ -198,4 +199,26 @@ 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())
}
}
@@ -1,40 +0,0 @@
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))
},
}
},
}
},
}
@@ -1,66 +0,0 @@
import { createAdaptorServer, type ServerType } from "@hono/node-server"
import { createNodeWebSocket } from "@hono/node-ws"
import type { Hono } from "hono"
import type { Adapter } from "./adapter"
export const adapter: Adapter = {
create(app: Hono) {
const ws = createNodeWebSocket({ app })
return {
upgradeWebSocket: ws.upgradeWebSocket,
async listen(opts) {
const start = (port: number) =>
new Promise<ServerType>((resolve, reject) => {
const server = createAdaptorServer({ fetch: app.fetch })
ws.injectWebSocket(server)
const fail = (err: Error) => {
cleanup()
reject(err)
}
const ready = () => {
cleanup()
resolve(server)
}
const cleanup = () => {
server.off("error", fail)
server.off("listening", ready)
}
server.once("error", fail)
server.once("listening", ready)
server.listen(port, opts.hostname)
})
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
const addr = server.address()
if (!addr || typeof addr === "string") {
throw new Error(`Failed to resolve server address for port ${opts.port}`)
}
let closing: Promise<void> | undefined
return {
port: addr.port,
stop(close?: boolean) {
closing ??= new Promise((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err)
return
}
resolve()
})
if (close) {
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
server.closeAllConnections()
}
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
server.closeIdleConnections()
}
}
})
return closing
},
}
},
}
},
}
-21
View File
@@ -1,21 +0,0 @@
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
}
@@ -1,150 +0,0 @@
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)
},
)
}
@@ -1,33 +1,52 @@
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 { 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"
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"
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
new Hono()
.use(WorkspaceRouterMiddleware(upgrade))
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))
.route("/project", ProjectRoutes())
.route("/pty", PtyRoutes(upgrade))
.route("/config", ConfigRoutes())
@@ -171,7 +190,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
},
}),
async (c) => {
const commands = await AppRuntime.runPromise(Command.Service.use((svc) => svc.list()))
const commands = await Command.list()
return c.json(commands)
},
)
@@ -258,6 +277,42 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
},
}),
async (c) => {
return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status())))
return c.json(await Format.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
}
})
+23 -82
View File
@@ -3,90 +3,31 @@ 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, MiddlewareHandler } from "hono"
import type { ErrorHandler } from "hono"
import { HTTPException } from "hono/http-exception"
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"
import type { Log } from "../util/log"
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,
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 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)
}
@@ -3,10 +3,12 @@ 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 { ServerProxy } from "./proxy"
import { lazy } from "@/util/lazy"
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"
@@ -45,7 +47,9 @@ async function getSessionWorkspace(url: URL) {
}
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
return async (c, next) => {
const routes = lazy(() => InstanceRoutes(upgrade))
return async (c) => {
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
const directory = Filesystem.resolve(
(() => {
@@ -68,7 +72,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
directory,
init: InstanceBootstrap,
async fn() {
return next()
return routes().fetch(c.req.raw, c.env)
},
})
}
@@ -83,7 +87,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 next()
return routes().fetch(c.req.raw, c.env)
}
return new Response(`Workspace not found: ${workspaceID}`, {
@@ -105,7 +109,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
directory: target.directory,
init: InstanceBootstrap,
async fn() {
return next()
return routes().fetch(c.req.raw, c.env)
},
}),
})
@@ -114,7 +118,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 next()
return routes().fetch(c.req.raw, c.env)
}
if (c.req.header("upgrade")?.toLowerCase() === "websocket") {
@@ -11,11 +11,9 @@ import { Session } from "../../session"
import { Config } from "../../config/config"
import { ConsoleState } from "../../config/console-state"
import { Account, AccountID, OrgID } from "../../account"
import { AppRuntime } from "../../effect/app-runtime"
import { zodToJsonSchema } from "zod-to-json-schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { Effect, Option } from "effect"
import { WorkspaceRoutes } from "./workspace"
import { Agent } from "@/agent/agent"
@@ -57,20 +55,11 @@ export const ExperimentalRoutes = lazy(() =>
},
}),
async (c) => {
const result = await AppRuntime.runPromise(
Effect.gen(function* () {
const config = yield* Config.Service
const account = yield* Account.Service
const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], {
concurrency: "unbounded",
})
return {
...state,
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
}
}),
)
return c.json(result)
const [consoleState, groups] = await Promise.all([Config.getConsoleState(), Account.orgsByAccount()])
return c.json({
...consoleState,
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
})
},
)
.get(
@@ -91,24 +80,17 @@ export const ExperimentalRoutes = lazy(() =>
},
}),
async (c) => {
const orgs = await AppRuntime.runPromise(
Effect.gen(function* () {
const account = yield* Account.Service
const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], {
concurrency: "unbounded",
})
const info = Option.getOrUndefined(active)
return groups.flatMap((group) =>
group.orgs.map((org) => ({
accountID: group.account.id,
accountEmail: group.account.email,
accountUrl: group.account.url,
orgID: org.id,
orgName: org.name,
active: !!info && info.id === group.account.id && info.active_org_id === org.id,
})),
)
}),
const [groups, active] = await Promise.all([Account.orgsByAccount(), Account.active()])
const orgs = groups.flatMap((group) =>
group.orgs.map((org) => ({
accountID: group.account.id,
accountEmail: group.account.email,
accountUrl: group.account.url,
orgID: org.id,
orgName: org.name,
active: !!active && active.id === group.account.id && active.active_org_id === org.id,
})),
)
return c.json({ orgs })
},
@@ -133,12 +115,7 @@ export const ExperimentalRoutes = lazy(() =>
validator("json", ConsoleSwitchBody),
async (c) => {
const body = c.req.valid("json")
await AppRuntime.runPromise(
Effect.gen(function* () {
const account = yield* Account.Service
yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID)))
}),
)
await Account.switchOrg(AccountID.make(body.accountID), OrgID.make(body.orgID))
return c.json(true)
},
)
@@ -1,12 +1,10 @@
import { Hono, type Context } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi"
import { streamSSE } from "hono/streaming"
import { Effect } from "effect"
import z from "zod"
import { BusEvent } from "@/bus/bus-event"
import { SyncEvent } from "@/sync"
import { GlobalBus } from "@/bus/global"
import { AppRuntime } from "@/effect/app-runtime"
import { AsyncQueue } from "@/util/queue"
import { Instance } from "../../project/instance"
import { Installation } from "@/installation"
@@ -292,41 +290,25 @@ export const GlobalRoutes = lazy(() =>
}),
),
async (c) => {
const result = await AppRuntime.runPromise(
Installation.Service.use((svc) =>
Effect.gen(function* () {
const method = yield* svc.method()
if (method === "unknown") {
return { success: false as const, status: 400 as const, error: "Unknown installation method" }
}
const target = c.req.valid("json").target || (yield* svc.latest(method))
const result = yield* Effect.catch(
svc.upgrade(method, target).pipe(Effect.as({ success: true as const, version: target })),
(err) =>
Effect.succeed({
success: false as const,
status: 500 as const,
error: err instanceof Error ? err.message : String(err),
}),
)
if (!result.success) return result
return { ...result, status: 200 as const }
}),
),
)
if (!result.success) {
return c.json({ success: false, error: result.error }, result.status)
const method = await Installation.method()
if (method === "unknown") {
return c.json({ success: false, error: "Unknown installation method" }, 400)
}
const target = result.version
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.Updated.type,
properties: { version: target },
},
})
return c.json({ success: true, version: target })
const target = c.req.valid("json").target || (await Installation.latest(method))
const result = await Installation.upgrade(method, target)
.then(() => ({ success: true as const, version: target }))
.catch((e) => ({ success: false as const, error: e instanceof Error ? e.message : String(e) }))
if (result.success) {
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.Updated.type,
properties: { version: target },
},
})
return c.json(result)
}
return c.json(result, 500)
},
),
)
@@ -6,7 +6,6 @@ import { Provider } from "../../provider/provider"
import { ModelsDev } from "../../provider/models"
import { ProviderAuth } from "../../provider/auth"
import { ProviderID } from "../../provider/schema"
import { AppRuntime } from "../../effect/app-runtime"
import { mapValues } from "remeda"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -82,7 +81,7 @@ export const ProviderRoutes = lazy(() =>
},
}),
async (c) => {
return c.json(await AppRuntime.runPromise(ProviderAuth.Service.use((svc) => svc.methods())))
return c.json(await ProviderAuth.methods())
},
)
.post(
@@ -119,15 +118,11 @@ export const ProviderRoutes = lazy(() =>
async (c) => {
const providerID = c.req.valid("param").providerID
const { method, inputs } = c.req.valid("json")
const result = await AppRuntime.runPromise(
ProviderAuth.Service.use((svc) =>
svc.authorize({
providerID,
method,
inputs,
}),
),
)
const result = await ProviderAuth.authorize({
providerID,
method,
inputs,
})
return c.json(result)
},
)
@@ -165,15 +160,11 @@ export const ProviderRoutes = lazy(() =>
async (c) => {
const providerID = c.req.valid("param").providerID
const { method, code } = c.req.valid("json")
await AppRuntime.runPromise(
ProviderAuth.Service.use((svc) =>
svc.callback({
providerID,
method,
code,
}),
),
)
await ProviderAuth.callback({
providerID,
method,
code,
})
return c.json(true)
},
),
@@ -3,7 +3,6 @@ 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"
@@ -28,7 +27,7 @@ export const QuestionRoutes = lazy(() =>
},
}),
async (c) => {
const questions = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
const questions = await Question.list()
return c.json(questions)
},
)
@@ -60,14 +59,10 @@ export const QuestionRoutes = lazy(() =>
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await AppRuntime.runPromise(
Question.Service.use((svc) =>
svc.reply({
requestID: params.requestID,
answers: json.answers,
}),
),
)
await Question.reply({
requestID: params.requestID,
answers: json.answers,
})
return c.json(true)
},
)
@@ -97,7 +92,7 @@ export const QuestionRoutes = lazy(() =>
),
async (c) => {
const params = c.req.valid("param")
await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(params.requestID)))
await Question.reject(params.requestID)
return c.json(true)
},
),
@@ -13,7 +13,6 @@ 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"
@@ -274,7 +273,6 @@ export const SessionRoutes = lazy(() =>
"json",
z.object({
title: z.string().optional(),
permission: Permission.Ruleset.optional(),
time: z
.object({
archived: z.number().optional(),
@@ -285,17 +283,10 @@ 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 })
}
@@ -725,17 +716,11 @@ export const SessionRoutes = lazy(() =>
),
async (c) => {
const params = c.req.valid("param")
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,
})
}),
)
await SessionRunState.assertNotBusy(params.sessionID)
await Session.removeMessage({
sessionID: params.sessionID,
messageID: params.messageID,
})
return c.json(true)
},
)
+275 -28
View File
@@ -1,14 +1,24 @@
import { generateSpecs } from "hono-openapi"
import { Log } from "../util/log"
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
import { Hono } from "hono"
import { adapter } 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 { MDNS } from "./mdns"
import { lazy } from "@/util/lazy"
import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
import { errorHandler } from "./middleware"
import { InstanceRoutes } from "./instance"
import { initProjectors } from "./projectors"
import { Log } from "@/util/log"
import { ControlPlaneRoutes } from "./control"
import { UIRoutes } from "./ui"
import { createAdaptorServer, type ServerType } from "@hono/node-server"
// @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
@@ -16,8 +26,6 @@ globalThis.AI_SDK_LOG_WARNINGS = false
initProjectors()
export namespace Server {
const log = Log.create({ service: "server" })
export type Listener = {
hostname: string
port: number
@@ -25,31 +33,231 @@ 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 runtime = adapter.create(app)
const ws = createNodeWebSocket({ app })
return {
app: app
.onError(ErrorMiddleware)
.use(AuthMiddleware)
.use(LoggerMiddleware)
.use(CompressionMiddleware)
.use(CorsMiddleware(opts))
.route("/", ControlPlaneRoutes())
.route("/", InstanceRoutes(runtime.upgradeWebSocket))
.route("/", UIRoutes()),
runtime,
app: ControlPlaneRoutes(ws.upgradeWebSocket, app, opts),
ws,
}
}
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 } = create({})
const { app, ws } = create({})
InstanceRoutes(ws.upgradeWebSocket, app)
const result = await generateSpecs(app, {
documentation: {
info: {
@@ -73,21 +281,46 @@ export namespace Server {
cors?: string[]
}): Promise<Listener> {
const built = create(opts)
const server = await built.runtime.listen(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 next = new URL("http://localhost")
next.hostname = opts.hostname
next.port = String(server.port)
next.port = String(addr.port)
url = next
const mdns =
opts.mdns &&
server.port &&
addr.port &&
opts.hostname !== "127.0.0.1" &&
opts.hostname !== "localhost" &&
opts.hostname !== "::1"
if (mdns) {
MDNS.publish(server.port, opts.mdnsDomain)
MDNS.publish(addr.port, opts.mdnsDomain)
} else if (opts.mdns) {
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
}
@@ -95,13 +328,27 @@ export namespace Server {
let closing: Promise<void> | undefined
return {
hostname: opts.hostname,
port: server.port,
port: addr.port,
url: next,
stop(close?: boolean) {
closing ??= (async () => {
closing ??= new Promise((resolve, reject) => {
if (mdns) MDNS.unpublish()
await server.stop(close)
})()
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
},
}
-55
View File
@@ -1,55 +0,0 @@
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
}
})
+21 -8
View File
@@ -353,10 +353,16 @@ export namespace Session {
delta: string
}) => Effect.Effect<void>
/** Finds the first message matching the predicate, searching newest-first. */
readonly findMessage: (
sessionID: SessionID,
predicate: (msg: MessageV2.WithParts) => boolean,
) => Effect.Effect<Option.Option<MessageV2.WithParts>>
readonly findMessage: {
<T extends MessageV2.WithParts>(
sessionID: SessionID,
predicate: (msg: MessageV2.WithParts) => msg is T,
): Effect.Effect<Option.Option<T>>
(
sessionID: SessionID,
predicate: (msg: MessageV2.WithParts) => boolean,
): Effect.Effect<Option.Option<MessageV2.WithParts>>
}
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Session") {}
@@ -708,10 +714,6 @@ 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) =>
@@ -854,4 +856,15 @@ export namespace Session {
MessageV2.Part.parse(part)
return runPromise((svc) => svc.updatePart(part))
}
export const updatePartDelta = fn(
z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod,
partID: PartID.zod,
field: z.string(),
delta: z.string(),
}),
(input) => runPromise((svc) => svc.updatePartDelta(input)),
)
}
@@ -4,6 +4,7 @@ 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"
@@ -237,7 +238,21 @@ 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))
}
}
+1 -3
View File
@@ -25,8 +25,6 @@ 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"
}
@@ -810,7 +808,7 @@ export namespace MessageV2 {
parts: [
{
type: "text" as const,
text: SYNTHETIC_ATTACHMENT_PROMPT,
text: "Attached image(s) from tool result:",
},
...media.map((attachment) => ({
type: "file" as const,
+42 -45
View File
@@ -102,8 +102,6 @@ export namespace SessionPrompt {
const instruction = yield* Instruction.Service
const state = yield* SessionRunState.Service
const revert = yield* SessionRevert.Service
const sys = yield* SystemPrompt.Service
const llm = yield* LLM.Service
const run = {
promise: <A, E>(effect: Effect.Effect<A, E>) =>
@@ -182,24 +180,21 @@ export namespace SessionPrompt {
const msgs = onlySubtasks
? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }]
: yield* MessageV2.toModelMessagesEffect(context, mdl)
const text = yield* llm
.stream({
const text = yield* Effect.promise(async (signal) => {
const result = await LLM.stream({
agent: ag,
user: firstInfo,
system: [],
small: true,
tools: {},
model: mdl,
abort: signal,
sessionID: input.session.id,
retries: 2,
messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs],
})
.pipe(
Stream.filter((e): e is Extract<LLM.Event, { type: "text-delta" }> => e.type === "text-delta"),
Stream.map((e) => e.text),
Stream.mkString,
Effect.orDie,
)
return result.text
})
const cleaned = text
.replace(/<think>[\s\S]*?<\/think>\s*/g, "")
.split("\n")
@@ -369,19 +364,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the
agent: input.agent.name,
messages: input.messages,
metadata: (val) =>
input.processor.updateToolCall(options.toolCallId, (match) => {
if (!["running", "pending"].includes(match.state.status)) return match
return {
...match,
state: {
title: val.title,
metadata: val.metadata,
status: "running",
input: args,
time: { start: Date.now() },
},
}
}),
run.promise(
input.processor.updateToolCall(options.toolCallId, (match) => {
if (!["running", "pending"].includes(match.state.status)) return match
return {
...match,
state: {
title: val.title,
metadata: val.metadata,
status: "running",
input: args,
time: { start: Date.now() },
},
}
}),
),
ask: (req) =>
permission
.ask({
@@ -595,14 +592,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the
callID: part.callID,
extra: { bypassAgentCheck: true, promptOps },
messages: msgs,
metadata: (val: { title?: string; metadata?: Record<string, any> }) =>
Effect.gen(function* () {
part = yield* sessions.updatePart({
...part,
type: "tool",
state: { ...part.state, ...val },
} satisfies MessageV2.ToolPart)
}),
metadata(val: { title?: string; metadata?: Record<string, any> }) {
return run.promise(
Effect.gen(function* () {
part = yield* sessions.updatePart({
...part,
type: "tool",
state: { ...part.state, ...val },
} satisfies MessageV2.ToolPart)
}),
)
},
ask: (req: any) =>
permission
.ask({
@@ -907,8 +907,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})
const lastModel = Effect.fnUntraced(function* (sessionID: SessionID) {
const match = yield* sessions.findMessage(sessionID, (m) => m.info.role === "user" && !!m.info.model)
if (Option.isSome(match) && match.value.info.role === "user") return match.value.info.model
const match = yield* sessions.findMessage(
sessionID,
(m): m is MessageV2.WithParts & { info: MessageV2.User } => m.info.role === "user" && !!m.info.model,
)
if (Option.isSome(match)) return match.value.info.model
return yield* provider.defaultModel()
})
@@ -1050,7 +1053,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
messageID: info.id,
extra: { bypassCwdCheck: true, ...extra },
messages: [],
metadata: () => Effect.void,
metadata: () => {},
ask: () => Effect.void,
})
.pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort())))
@@ -1467,8 +1470,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const [skills, env, instructions, modelMsgs] = yield* Effect.all([
sys.skills(agent),
Effect.sync(() => sys.environment(model)),
Effect.promise(() => SystemPrompt.skills(agent)),
Effect.promise(() => SystemPrompt.environment(model)),
instruction.system().pipe(Effect.orDie),
MessageV2.toModelMessagesEffect(msgs, model),
])
@@ -1692,15 +1695,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
Layer.provide(Plugin.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(SessionRevert.defaultLayer),
Layer.provide(
Layer.mergeAll(
Agent.defaultLayer,
SystemPrompt.defaultLayer,
LLM.defaultLayer,
Bus.layer,
CrossSpawnSpawner.defaultLayer,
),
),
Layer.provide(Agent.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
@@ -1,5 +1,6 @@
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"
@@ -105,4 +106,9 @@ 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))
}
}
+37 -45
View File
@@ -1,4 +1,4 @@
import { Context, Effect, Layer } from "effect"
import { Ripgrep } from "../file/ripgrep"
import { Instance } from "../project/instance"
@@ -33,52 +33,44 @@ export namespace SystemPrompt {
return [PROMPT_DEFAULT]
}
export interface Interface {
readonly environment: (model: Provider.Model) => string[]
readonly skills: (agent: Agent.Info) => Effect.Effect<string | undefined>
export async function environment(model: Provider.Model) {
const project = Instance.project
return [
[
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
`Here is some useful information about the environment you are running in:`,
`<env>`,
` Working directory: ${Instance.directory}`,
` Workspace root folder: ${Instance.worktree}`,
` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
` Today's date: ${new Date().toDateString()}`,
`</env>`,
`<directories>`,
` ${
project.vcs === "git" && false
? await Ripgrep.tree({
cwd: Instance.directory,
limit: 50,
})
: ""
}`,
`</directories>`,
].join("\n"),
]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SystemPrompt") {}
export async function skills(agent: Agent.Info) {
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const skill = yield* Skill.Service
const list = await Skill.available(agent)
return Service.of({
environment(model) {
const project = Instance.project
return [
[
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
`Here is some useful information about the environment you are running in:`,
`<env>`,
` Working directory: ${Instance.directory}`,
` Workspace root folder: ${Instance.worktree}`,
` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
` Today's date: ${new Date().toDateString()}`,
`</env>`,
].join("\n"),
]
},
skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) {
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
const list = yield* skill.available(agent)
return [
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
// the agents seem to ingest the information about skills a bit better if we present a more verbose
// version of them here and a less verbose version in tool description, rather than vice versa.
Skill.fmt(list, { verbose: true }),
].join("\n")
}),
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer))
return [
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
// the agents seem to ingest the information about skills a bit better if we present a more verbose
// version of them here and a less verbose version in tool description, rather than vice versa.
Skill.fmt(list, { verbose: true }),
].join("\n")
}
}
+7 -87
View File
@@ -177,39 +177,8 @@ 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(
filtered.map((item) =>
all.map((item) =>
fs
.stat(path.join(state.directory, item))
.pipe(Effect.catch(() => Effect.void))
@@ -290,39 +259,14 @@ 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: files.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
files: result.text
.trim()
.split("\n")
.map((x) => x.trim())
.filter(Boolean)
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
}
}),
)
@@ -672,30 +616,6 @@ 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 }))
+57 -56
View File
@@ -385,7 +385,7 @@ export const BashTool = Tool.define(
let expired = false
let aborted = false
yield* ctx.metadata({
ctx.metadata({
metadata: {
output: "",
description: input.description,
@@ -397,15 +397,17 @@ export const BashTool = Tool.define(
const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env))
yield* Effect.forkScoped(
Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
output += chunk
return ctx.metadata({
metadata: {
output: preview(output),
description: input.description,
},
})
}),
Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
Effect.sync(() => {
output += chunk
ctx.metadata({
metadata: {
output: preview(output),
description: input.description,
},
})
}),
),
)
const abort = Effect.callback<void>((resume) => {
@@ -454,53 +456,52 @@ export const BashTool = Tool.define(
}
})
return () =>
Effect.sync(() => {
const shell = Shell.acceptable()
const name = Shell.name(shell)
const chain =
name === "powershell"
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
log.info("bash tool using shell", { shell })
return async () => {
const shell = Shell.acceptable()
const name = Shell.name(shell)
const chain =
name === "powershell"
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
log.info("bash tool using shell", { shell })
return {
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
.replaceAll("${os}", process.platform)
.replaceAll("${shell}", name)
.replaceAll("${chaining}", chain)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const cwd = params.workdir
? yield* resolvePath(params.workdir, Instance.directory, shell)
: Instance.directory
if (params.timeout !== undefined && params.timeout < 0) {
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
}
const timeout = params.timeout ?? DEFAULT_TIMEOUT
const ps = PS.has(name)
const root = yield* parse(params.command, ps)
const scan = yield* collect(root, cwd, ps, shell)
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
yield* ask(ctx, scan)
return {
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
.replaceAll("${os}", process.platform)
.replaceAll("${shell}", name)
.replaceAll("${chaining}", chain)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const cwd = params.workdir
? yield* resolvePath(params.workdir, Instance.directory, shell)
: Instance.directory
if (params.timeout !== undefined && params.timeout < 0) {
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
}
const timeout = params.timeout ?? DEFAULT_TIMEOUT
const ps = PS.has(name)
const root = yield* parse(params.command, ps)
const scan = yield* collect(root, cwd, ps, shell)
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
yield* ask(ctx, scan)
return yield* run(
{
shell,
name,
command: params.command,
cwd,
env: yield* shellEnv(ctx, cwd),
timeout,
description: params.description,
},
ctx,
)
}),
}
})
return yield* run(
{
shell,
name,
command: params.command,
cwd,
env: yield* shellEnv(ctx, cwd),
timeout,
description: params.description,
},
ctx,
)
}),
}
}
}),
)
+1 -1
View File
@@ -158,7 +158,7 @@ export const EditTool = Tool.define(
if (change.removed) filediff.deletions += change.count || 0
}
yield* ctx.metadata({
ctx.metadata({
metadata: {
diff,
filediff,
+1 -1
View File
@@ -10,7 +10,7 @@ export const MultiEditTool = Tool.define(
"multiedit",
Effect.gen(function* () {
const editInfo = yield* EditTool
const edit = yield* editInfo.init()
const edit = yield* Effect.promise(() => editInfo.init())
return {
description: DESCRIPTION,
+2 -1
View File
@@ -30,6 +30,7 @@ import { Glob } from "../util/glob"
import path from "path"
import { pathToFileURL } from "url"
import { Effect, Layer, Context } from "effect"
import { EffectLogger } from "@/effect/logger"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
@@ -136,7 +137,7 @@ export namespace ToolRegistry {
Effect.gen(function* () {
const pluginCtx: PluginToolContext = {
...toolCtx,
ask: (req) => toolCtx.ask(req),
ask: (req) => Effect.runPromise(toolCtx.ask(req).pipe(Effect.provide(EffectLogger.layer))),
directory: ctx.directory,
worktree: ctx.worktree,
}
+75 -75
View File
@@ -17,84 +17,84 @@ export const SkillTool = Tool.define(
Effect.gen(function* () {
const skill = yield* Skill.Service
const rg = yield* Ripgrep.Service
return () =>
Effect.gen(function* () {
const list = yield* skill.available().pipe(Effect.provide(EffectLogger.layer))
const description =
list.length === 0
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
: [
"Load a specialized skill that provides domain-specific instructions and workflows.",
return async () => {
const list = await Effect.runPromise(skill.available().pipe(Effect.provide(EffectLogger.layer)))
const description =
list.length === 0
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
: [
"Load a specialized skill that provides domain-specific instructions and workflows.",
"",
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"",
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"",
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
Skill.fmt(list, { verbose: false }),
].join("\n")
return {
description,
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const info = yield* skill.get(params.name)
if (!info) {
const all = yield* skill.all()
const available = all.map((s) => s.name).join(", ")
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}
yield* ctx.ask({
permission: "skill",
patterns: [params.name],
always: [params.name],
metadata: {},
})
const dir = path.dirname(info.location)
const base = pathToFileURL(dir).href
const limit = 10
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
Stream.filter((file) => !file.includes("SKILL.md")),
Stream.map((file) => path.resolve(dir, file)),
Stream.take(limit),
Stream.runCollect,
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
)
return {
title: `Loaded skill: ${info.name}`,
output: [
`<skill_content name="${info.name}">`,
`# Skill: ${info.name}`,
"",
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
info.content.trim(),
"",
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
`Base directory for this skill: ${base}`,
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
"Note: file list is sampled.",
"",
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
Skill.fmt(list, { verbose: false }),
].join("\n")
return {
description,
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const info = yield* skill.get(params.name)
if (!info) {
const all = yield* skill.all()
const available = all.map((s) => s.name).join(", ")
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}
yield* ctx.ask({
permission: "skill",
patterns: [params.name],
always: [params.name],
metadata: {},
})
const dir = path.dirname(info.location)
const base = pathToFileURL(dir).href
const limit = 10
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
Stream.filter((file) => !file.includes("SKILL.md")),
Stream.map((file) => path.resolve(dir, file)),
Stream.take(limit),
Stream.runCollect,
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
)
return {
title: `Loaded skill: ${info.name}`,
output: [
`<skill_content name="${info.name}">`,
`# Skill: ${info.name}`,
"",
info.content.trim(),
"",
`Base directory for this skill: ${base}`,
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
"Note: file list is sampled.",
"",
"<skill_files>",
files,
"</skill_files>",
"</skill_content>",
].join("\n"),
metadata: {
name: info.name,
dir,
},
}
}).pipe(Effect.orDie),
}
})
"<skill_files>",
files,
"</skill_files>",
"</skill_content>",
].join("\n"),
metadata: {
name: info.name,
dir,
},
}
}).pipe(Effect.orDie),
}
}
}),
)
+36 -32
View File
@@ -36,7 +36,6 @@ export const TaskTool = Tool.define(
Effect.gen(function* () {
const agent = yield* Agent.Service
const config = yield* Config.Service
const sessions = yield* Session.Service
const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
const cfg = yield* config.get()
@@ -63,39 +62,44 @@ export const TaskTool = Tool.define(
const taskID = params.task_id
const session = taskID
? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
? yield* Effect.promise(() => {
const id = SessionID.make(taskID)
return Session.get(id).catch(() => undefined)
})
: undefined
const nextSession =
session ??
(yield* sessions.create({
parentID: ctx.sessionID,
title: params.description + ` (@${next.name} subagent)`,
permission: [
...(canTodo
? []
: [
{
permission: "todowrite" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(canTask
? []
: [
{
permission: id,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(cfg.experimental?.primary_tools?.map((item) => ({
pattern: "*",
action: "allow" as const,
permission: item,
})) ?? []),
],
}))
(yield* Effect.promise(() =>
Session.create({
parentID: ctx.sessionID,
title: params.description + ` (@${next.name} subagent)`,
permission: [
...(canTodo
? []
: [
{
permission: "todowrite" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(canTask
? []
: [
{
permission: id,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(cfg.experimental?.primary_tools?.map((item) => ({
pattern: "*",
action: "allow" as const,
permission: item,
})) ?? []),
],
}),
))
const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }))
if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message"))
@@ -105,7 +109,7 @@ export const TaskTool = Tool.define(
providerID: msg.info.providerID,
}
yield* ctx.metadata({
ctx.metadata({
title: params.description,
metadata: {
sessionId: nextSession.id,
+42 -54
View File
@@ -22,7 +22,7 @@ export namespace Tool {
callID?: string
extra?: { [key: string]: any }
messages: MessageV2.WithParts[]
metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>
metadata(input: { title?: string; metadata?: M }): void
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
}
@@ -47,13 +47,9 @@ export namespace Tool {
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
init: () => Effect.Effect<DefWithoutID<Parameters, M>>
init: () => Promise<DefWithoutID<Parameters, M>>
}
type Init<Parameters extends z.ZodType, M extends Metadata> =
| DefWithoutID<Parameters, M>
| (() => Effect.Effect<DefWithoutID<Parameters, M>>)
export type InferParameters<T> =
T extends Info<infer P, any>
? z.infer<P>
@@ -72,66 +68,58 @@ export namespace Tool {
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
id: string,
init: Init<Parameters, Result>,
truncate: Truncate.Interface,
agents: Agent.Interface,
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
) {
return () =>
Effect.gen(function* () {
const toolInfo = init instanceof Function ? { ...(yield* init()) } : { ...init }
const execute = toolInfo.execute
toolInfo.execute = (args, ctx) =>
Effect.gen(function* () {
yield* Effect.try({
try: () => toolInfo.parameters.parse(args),
catch: (error) => {
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
return new Error(toolInfo.formatValidationError(error), { cause: error })
}
return new Error(
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
{ cause: error },
)
},
})
const result = yield* execute(args, ctx)
if (result.metadata.truncated !== undefined) {
return result
}
const agent = yield* agents.get(ctx.agent)
const truncated = yield* truncate.output(result.output, {}, agent)
return {
...result,
output: truncated.content,
metadata: {
...result.metadata,
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
},
}
}).pipe(Effect.orDie)
return toolInfo
})
return async () => {
const toolInfo = init instanceof Function ? await init() : { ...init }
const execute = toolInfo.execute
toolInfo.execute = (args, ctx) =>
Effect.gen(function* () {
yield* Effect.try({
try: () => toolInfo.parameters.parse(args),
catch: (error) => {
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
return new Error(toolInfo.formatValidationError(error), { cause: error })
}
return new Error(
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
{ cause: error },
)
},
})
const result = yield* execute(args, ctx)
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))
return {
...result,
output: truncated.content,
metadata: {
...result.metadata,
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
},
}
}).pipe(Effect.orDie)
return toolInfo
}
}
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 | Truncate.Service | Agent.Service> & { id: ID } {
init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>,
): Effect.Effect<Info<Parameters, Result>, never, R> & { id: ID } {
return Object.assign(
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) }
}),
Effect.map(init, (next) => ({ id, init: wrap(id, next) })),
{ id },
)
}
export function init<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> {
return Effect.gen(function* () {
const init = yield* info.init()
const init = yield* Effect.promise(() => info.init())
return {
...init,
id: info.id,
+7
View File
@@ -2,6 +2,7 @@ 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"
@@ -134,4 +135,10 @@ 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))
}
}
+8 -13
View File
@@ -15,7 +15,6 @@ export namespace Log {
WARN: 2,
ERROR: 3,
}
const keep = 10
let level: Level = "INFO"
@@ -79,19 +78,15 @@ export namespace Log {
}
async function cleanup(dir: string) {
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 files = await Glob.scan("????-??-??T??????.log", {
cwd: dir,
absolute: true,
include: "file",
})
if (files.length <= 5) return
const doomed = files.slice(0, -keep)
await Promise.all(doomed.map((file) => fs.unlink(path.join(dir, file)).catch(() => {})))
const filesToDelete = files.slice(0, -10)
await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {})))
}
function formatError(error: Error, depth = 0): string {
-115
View File
@@ -1,115 +0,0 @@
import { Identifier } from "@/id/id"
import { withStatics } from "@/util/schema"
import { DateTime, Effect, Schema } from "effect"
export namespace Message {
export const ID = Schema.String.pipe(Schema.brand("Message.ID")).pipe(
withStatics((s) => ({
create: () => s.make(Identifier.ascending("message")),
prefix: "msg",
})),
)
export class 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 FileAttachment({
uri: url,
mime: "text/plain",
})
}
}
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,
}),
}) {
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),
},
})
return msg
}
}
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>
}
-71
View File
@@ -1,71 +0,0 @@
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),
})
}
}
-52
View File
@@ -79,55 +79,3 @@ 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.
+352 -329
View File
@@ -1,422 +1,445 @@
import { afterEach, describe, expect } from "bun:test"
import fs from "fs/promises"
import { describe, test, expect, afterEach } from "bun:test"
import path from "path"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import fs from "fs/promises"
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 { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { tmpdir } from "../fixture/fixture"
afterEach(async () => {
await Instance.disposeAll()
})
const it = testEffect(Layer.mergeAll(FileTime.defaultLayer, CrossSpawnSpawner.defaultLayer))
async function touch(file: string, time: number) {
const date = new Date(time)
await fs.utimes(file, date, date)
}
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)
function gate() {
let open!: () => void
const wait = new Promise<void>((resolve) => {
open = resolve
})
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")
})
return { open, wait }
}
describe("file/time", () => {
describe("read() and get()", () => {
it.live("stores read timestamp", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
const sessionID = SessionID.make("ses_00000000000000000000000001")
const before = yield* get(id, file)
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")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await FileTime.get(sessionID, filepath)
expect(before).toBeUndefined()
yield* read(id, file)
await FileTime.read(sessionID, filepath)
const after = yield* get(id, file)
const after = await FileTime.get(sessionID, filepath)
expect(after).toBeInstanceOf(Date)
expect(after!.getTime()).toBeGreaterThan(0)
}),
),
)
},
})
})
it.live("tracks separate timestamps per session", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
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")
const one = SessionID.make("ses_00000000000000000000000002")
const two = SessionID.make("ses_00000000000000000000000003")
yield* read(one, file)
yield* read(two, file)
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 first = yield* get(one, file)
const second = yield* get(two, file)
const time1 = await FileTime.get(SessionID.make("ses_00000000000000000000000002"), filepath)
const time2 = await FileTime.get(SessionID.make("ses_00000000000000000000000003"), filepath)
expect(first).toBeDefined()
expect(second).toBeDefined()
}),
),
)
expect(time1).toBeDefined()
expect(time2).toBeDefined()
},
})
})
it.live("updates timestamp on subsequent reads", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
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")
yield* read(id, file)
const first = yield* get(id, file)
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 second = yield* get(id, file)
await FileTime.read(sessionID, filepath)
const second = await FileTime.get(sessionID, filepath)
expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime())
}),
),
)
},
})
})
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")
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")
yield* provideInstance(one)(read(id, file))
const result = yield* provideInstance(two)(get(id, file))
expect(result).toBeUndefined()
}),
)
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()
},
})
})
})
describe("assert()", () => {
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)
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)
yield* read(id, file)
yield* check(id, file)
}),
),
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
await FileTime.assert(sessionID, filepath)
},
})
})
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")
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")
const err = yield* fail(check(id, file))
expect(err.message).toContain("You must read file")
}),
),
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("You must read file")
},
})
})
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)
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)
yield* read(id, file)
yield* put(file, "modified content")
yield* touch(file, 2_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")
},
})
})
const err = yield* fail(check(id, file))
expect(err.message).toContain("modified since it was last read")
}),
),
)
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)
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)
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)
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:")
}),
),
)
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:")
},
})
})
})
describe("withLock()", () => {
it.live("executes function within lock", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
let hit = false
test("executes function within lock", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
yield* lock(file, () =>
Effect.sync(() => {
hit = true
return "result"
}),
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
let executed = false
await FileTime.withLock(filepath, async () => {
executed = true
return "result"
})
expect(executed).toBe(true)
},
})
})
expect(hit).toBe(true)
}),
),
)
test("returns function result", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
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"))
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await FileTime.withLock(filepath, async () => {
return "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")
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 = yield* Deferred.make<void>()
const ready = yield* Deferred.make<void>()
const hold = gate()
const ready = gate()
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)
const op1 = FileTime.withLock(filepath, async () => {
order.push(1)
ready.open()
await hold.wait
order.push(2)
})
yield* Deferred.await(ready)
await ready.wait
const two = yield* lock(file, () =>
Effect.sync(() => {
order.push(3)
order.push(4)
}),
).pipe(Effect.forkScoped)
const op2 = FileTime.withLock(filepath, async () => {
order.push(3)
order.push(4)
})
yield* Deferred.succeed(hold, void 0)
yield* Fiber.join(one)
yield* Fiber.join(two)
hold.open()
await Promise.all([op1, op2])
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>()
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")
const a = yield* lock(onefile, () =>
Effect.gen(function* () {
one = true
yield* Deferred.succeed(ready, void 0)
yield* Deferred.await(hold)
expect(two).toBe(true)
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")
}),
).pipe(Effect.forkScoped)
).rejects.toThrow("Test error")
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)
}),
),
)
let executed = false
await FileTime.withLock(filepath, async () => {
executed = true
})
expect(executed).toBe(true)
},
})
})
})
describe("path normalization", () => {
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)
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)
const forward = file.replaceAll("\\", "/")
yield* read(id, forward)
yield* check(id, file)
}),
),
)
const forwardSlash = filepath.replaceAll("\\", "/")
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)
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)
},
})
})
const forward = file.replaceAll("\\", "/")
yield* read(id, file)
yield* check(id, forward)
}),
),
)
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)
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")
const forwardSlash = filepath.replaceAll("\\", "/")
const forward = file.replaceAll("\\", "/")
yield* read(id, forward)
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 result = yield* get(id, file)
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)
expect(result).toBeInstanceOf(Date)
}),
),
)
},
})
})
it.live("withLock serializes regardless of slash direction", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
const forward = file.replaceAll("\\", "/")
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 () => {
const order: number[] = []
const hold = yield* Deferred.make<void>()
const ready = yield* Deferred.make<void>()
const hold = gate()
const ready = gate()
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)
const op1 = FileTime.withLock(filepath, async () => {
order.push(1)
ready.open()
await hold.wait
order.push(2)
})
yield* Deferred.await(ready)
await ready.wait
const two = yield* lock(forward, () =>
Effect.sync(() => {
order.push(3)
order.push(4)
}),
).pipe(Effect.forkScoped)
// Use forward-slash variant -- should still serialize against op1
const op2 = FileTime.withLock(forwardSlash, async () => {
order.push(3)
order.push(4)
})
yield* Deferred.succeed(hold, void 0)
yield* Fiber.join(one)
yield* Fiber.join(two)
hold.open()
await Promise.all([op1, op2])
expect(order).toEqual([1, 2, 3, 4])
}),
),
)
},
})
})
})
describe("stat() Filesystem.stat pattern", () => {
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)
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)
yield* read(id, file)
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
const stat = Filesystem.stat(file)
expect(stat?.mtime).toBeInstanceOf(Date)
expect(stat!.mtime.getTime()).toBeGreaterThan(0)
const stats = Filesystem.stat(filepath)
expect(stats?.mtime).toBeInstanceOf(Date)
expect(stats!.mtime.getTime()).toBeGreaterThan(0)
yield* check(id, file)
}),
),
)
await FileTime.assert(sessionID, filepath)
},
})
})
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)
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)
yield* read(id, file)
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
const first = Filesystem.stat(file)
const originalStat = Filesystem.stat(filepath)
yield* put(file, "modified")
yield* touch(file, 2_000)
await fs.writeFile(filepath, "modified", "utf-8")
await touch(filepath, 2_000)
const second = Filesystem.stat(file)
expect(second!.mtime.getTime()).toBeGreaterThan(first!.mtime.getTime())
const newStat = Filesystem.stat(filepath)
expect(newStat!.mtime.getTime()).toBeGreaterThan(originalStat!.mtime.getTime())
yield* fail(check(id, file))
}),
),
)
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow()
},
})
})
})
})
@@ -1,49 +0,0 @@
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)
}
@@ -1,127 +0,0 @@
import { describe, test, expect } from "bun:test"
import path from "path"
const projectRoot = path.join(import.meta.dir, "../..")
const worker = path.join(import.meta.dir, "abort-leak-webfetch.ts")
const MB = 1024 * 1024
const ITERATIONS = 50
const getHeapMB = () => {
Bun.gc(true)
return process.memoryUsage().heapUsed / MB
}
describe("memory: abort controller leak", () => {
test("webfetch does not leak memory over many invocations", async () => {
// Measure the abort-timed fetch path in a fresh process so shared tool
// runtime state does not dominate the heap signal.
const proc = Bun.spawn({
cmd: [process.execPath, worker],
cwd: projectRoot,
stdout: "pipe",
stderr: "pipe",
env: process.env,
})
const [code, stdout, stderr] = await Promise.all([
proc.exited,
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
])
if (code !== 0) {
throw new Error(stderr.trim() || stdout.trim() || `worker exited with code ${code}`)
}
const result = JSON.parse(stdout.trim()) as {
baseline: number
after: number
growth: number
}
console.log(`Baseline: ${result.baseline.toFixed(2)} MB`)
console.log(`After ${ITERATIONS} fetches: ${result.after.toFixed(2)} MB`)
console.log(`Growth: ${result.growth.toFixed(2)} MB`)
// Memory growth should be minimal - less than 1MB per 10 requests.
expect(result.growth).toBeLessThan(ITERATIONS / 10)
}, 60000)
test("compare closure vs bind pattern directly", async () => {
const ITERATIONS = 500
// Test OLD pattern: arrow function closure
// Store closures in a map keyed by content to force retention
const closureMap = new Map<string, () => void>()
const timers: Timer[] = []
const controllers: AbortController[] = []
Bun.gc(true)
Bun.sleepSync(100)
const baseline = getHeapMB()
for (let i = 0; i < ITERATIONS; i++) {
// Simulate large response body like webfetch would have
const content = `${i}:${"x".repeat(50 * 1024)}` // 50KB unique per iteration
const controller = new AbortController()
controllers.push(controller)
// OLD pattern - closure captures `content`
const handler = () => {
// Actually use content so it can't be optimized away
if (content.length > 1000000000) controller.abort()
}
closureMap.set(content, handler)
const timeoutId = setTimeout(handler, 30000)
timers.push(timeoutId)
}
Bun.gc(true)
Bun.sleepSync(100)
const after = getHeapMB()
const oldGrowth = after - baseline
console.log(`OLD pattern (closure): ${oldGrowth.toFixed(2)} MB growth (${closureMap.size} closures)`)
// Cleanup after measuring
timers.forEach(clearTimeout)
controllers.forEach((c) => c.abort())
closureMap.clear()
// Test NEW pattern: bind
Bun.gc(true)
Bun.sleepSync(100)
const baseline2 = getHeapMB()
const handlers2: (() => void)[] = []
const timers2: Timer[] = []
const controllers2: AbortController[] = []
for (let i = 0; i < ITERATIONS; i++) {
const _content = `${i}:${"x".repeat(50 * 1024)}` // 50KB - won't be captured
const controller = new AbortController()
controllers2.push(controller)
// NEW pattern - bind doesn't capture surrounding scope
const handler = controller.abort.bind(controller)
handlers2.push(handler)
const timeoutId = setTimeout(handler, 30000)
timers2.push(timeoutId)
}
Bun.gc(true)
Bun.sleepSync(100)
const after2 = getHeapMB()
const newGrowth = after2 - baseline2
// Cleanup after measuring
timers2.forEach(clearTimeout)
controllers2.forEach((c) => c.abort())
handlers2.length = 0
console.log(`NEW pattern (bind): ${newGrowth.toFixed(2)} MB growth`)
console.log(`Improvement: ${(oldGrowth - newGrowth).toFixed(2)} MB saved`)
expect(newGrowth).toBeLessThanOrEqual(oldGrowth)
})
})
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { Effect } from "effect"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { ProviderAuth } from "../../src/provider/auth"
@@ -40,18 +39,14 @@ describe("plugin.auth-override", () => {
const methods = await Instance.provide({
directory: tmp.path,
fn: async () => {
return Effect.runPromise(
ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(ProviderAuth.defaultLayer)),
)
return ProviderAuth.methods()
},
})
const plainMethods = await Instance.provide({
directory: plain.path,
fn: async () => {
return Effect.runPromise(
ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(ProviderAuth.defaultLayer)),
)
return ProviderAuth.methods()
},
})
@@ -1,6 +1,5 @@
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
@@ -116,45 +115,3 @@ 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")
})
@@ -13,6 +13,8 @@ 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) {
@@ -35,6 +37,27 @@ 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({
@@ -161,13 +184,14 @@ describe("plugin.loader.shared", () => {
},
})
await load(tmp.path)
const errors = await errs(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 () => {
@@ -199,13 +223,14 @@ describe("plugin.loader.shared", () => {
},
})
await load(tmp.path)
const errors = await errs(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 () => {
@@ -358,7 +383,8 @@ describe("plugin.loader.shared", () => {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
await load(tmp.path)
const errors = await errs(tmp.path)
expect(errors).toHaveLength(0)
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
} finally {
install.mockRestore()
@@ -410,7 +436,8 @@ describe("plugin.loader.shared", () => {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
await load(tmp.path)
const errors = await errs(tmp.path)
expect(errors).toHaveLength(0)
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
} finally {
install.mockRestore()
@@ -455,13 +482,14 @@ describe("plugin.loader.shared", () => {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
await load(tmp.path)
const errors = await errs(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()
}
@@ -518,12 +546,13 @@ describe("plugin.loader.shared", () => {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
await load(tmp.path)
const errors = await errs(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()
}
@@ -559,49 +588,30 @@ describe("plugin.loader.shared", () => {
}
})
test("skips broken plugin when install fails", async () => {
test("publishes session.error when install fails", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
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 }
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["broken-plugin@9.9.9"] }, null, 2))
},
})
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
try {
await load(tmp.path)
expect(install).toHaveBeenCalledWith("broken-plugin@9.9.9")
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
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,
)
} finally {
install.mockRestore()
}
})
test("continues loading plugins when plugin init throws", async () => {
test("publishes session.error 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"),
[
@@ -614,91 +624,51 @@ 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, ok] }, null, 2))
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
return { mark }
return { file }
},
})
await load(tmp.path)
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
const errors = await errs(tmp.path)
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}: explode`))).toBe(true)
})
test("continues loading plugins when plugin module has invalid export", async () => {
test("publishes session.error 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, ok] }, null, 2))
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
return { mark }
return { file }
},
})
await load(tmp.path)
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
const errors = await errs(tmp.path)
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}`))).toBe(true)
})
test("continues loading plugins when plugin import fails", async () => {
test("publishes session.error when plugin import fails", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href
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))
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing] }, null, 2))
return { mark }
return { missing }
},
})
await load(tmp.path)
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
const errors = await errs(tmp.path)
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.missing}`))).toBe(true)
})
test("loads object plugin via plugin.server", async () => {
+1 -2
View File
@@ -3,7 +3,6 @@ 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"
@@ -20,7 +19,7 @@ async function withVcs(directory: string, body: () => Promise<void>) {
return Instance.provide({
directory,
fn: async () => {
void AppRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init()))
FileWatcher.init()
Vcs.init()
await Bun.sleep(500)
await body()
@@ -104,58 +104,6 @@ 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"
@@ -4,17 +4,6 @@ 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()
@@ -22,9 +11,9 @@ afterEach(async () => {
/** Reject all pending questions so dangling Deferred fibers don't hang the test. */
async function rejectAll() {
const pending = await list()
const pending = await Question.list()
for (const req of pending) {
await reject(req.id)
await Question.reject(req.id)
}
}
@@ -33,7 +22,7 @@ test("ask - returns pending promise", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const promise = ask({
const promise = Question.ask({
sessionID: SessionID.make("ses_test"),
questions: [
{
@@ -69,16 +58,16 @@ test("ask - adds to pending list", async () => {
},
]
const promise = ask({
const askPromise = Question.ask({
sessionID: SessionID.make("ses_test"),
questions,
})
const pending = await list()
const pending = await Question.list()
expect(pending.length).toBe(1)
expect(pending[0].questions).toEqual(questions)
await rejectAll()
await promise.catch(() => {})
await askPromise.catch(() => {})
},
})
})
@@ -101,20 +90,20 @@ test("reply - resolves the pending ask with answers", async () => {
},
]
const promise = ask({
const askPromise = Question.ask({
sessionID: SessionID.make("ses_test"),
questions,
})
const pending = await list()
const pending = await Question.list()
const requestID = pending[0].id
await reply({
await Question.reply({
requestID,
answers: [["Option 1"]],
})
const answers = await promise
const answers = await askPromise
expect(answers).toEqual([["Option 1"]])
},
})
@@ -125,7 +114,7 @@ test("reply - removes from pending list", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const promise = ask({
const askPromise = Question.ask({
sessionID: SessionID.make("ses_test"),
questions: [
{
@@ -139,17 +128,17 @@ test("reply - removes from pending list", async () => {
],
})
const pending = await list()
const pending = await Question.list()
expect(pending.length).toBe(1)
await reply({
await Question.reply({
requestID: pending[0].id,
answers: [["Option 1"]],
})
await promise
await askPromise
const after = await list()
expect(after.length).toBe(0)
const pendingAfter = await Question.list()
expect(pendingAfter.length).toBe(0)
},
})
})
@@ -159,7 +148,7 @@ test("reply - does nothing for unknown requestID", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await reply({
await Question.reply({
requestID: QuestionID.make("que_unknown"),
answers: [["Option 1"]],
})
@@ -175,7 +164,7 @@ test("reject - throws RejectedError", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const promise = ask({
const askPromise = Question.ask({
sessionID: SessionID.make("ses_test"),
questions: [
{
@@ -189,10 +178,10 @@ test("reject - throws RejectedError", async () => {
],
})
const pending = await list()
await reject(pending[0].id)
const pending = await Question.list()
await Question.reject(pending[0].id)
await expect(promise).rejects.toBeInstanceOf(Question.RejectedError)
await expect(askPromise).rejects.toBeInstanceOf(Question.RejectedError)
},
})
})
@@ -202,7 +191,7 @@ test("reject - removes from pending list", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const promise = ask({
const askPromise = Question.ask({
sessionID: SessionID.make("ses_test"),
questions: [
{
@@ -216,14 +205,14 @@ test("reject - removes from pending list", async () => {
],
})
const pending = await list()
const pending = await Question.list()
expect(pending.length).toBe(1)
await reject(pending[0].id)
promise.catch(() => {}) // Ignore rejection
await Question.reject(pending[0].id)
askPromise.catch(() => {}) // Ignore rejection
const after = await list()
expect(after.length).toBe(0)
const pendingAfter = await Question.list()
expect(pendingAfter.length).toBe(0)
},
})
})
@@ -233,7 +222,7 @@ test("reject - does nothing for unknown requestID", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await reject(QuestionID.make("que_unknown"))
await Question.reject(QuestionID.make("que_unknown"))
// Should not throw
},
})
@@ -265,19 +254,19 @@ test("ask - handles multiple questions", async () => {
},
]
const promise = ask({
const askPromise = Question.ask({
sessionID: SessionID.make("ses_test"),
questions,
})
const pending = await list()
const pending = await Question.list()
await reply({
await Question.reply({
requestID: pending[0].id,
answers: [["Build"], ["Dev"]],
})
const answers = await promise
const answers = await askPromise
expect(answers).toEqual([["Build"], ["Dev"]])
},
})
@@ -290,7 +279,7 @@ test("list - returns all pending requests", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const p1 = ask({
const p1 = Question.ask({
sessionID: SessionID.make("ses_test1"),
questions: [
{
@@ -301,7 +290,7 @@ test("list - returns all pending requests", async () => {
],
})
const p2 = ask({
const p2 = Question.ask({
sessionID: SessionID.make("ses_test2"),
questions: [
{
@@ -312,7 +301,7 @@ test("list - returns all pending requests", async () => {
],
})
const pending = await list()
const pending = await Question.list()
expect(pending.length).toBe(2)
await rejectAll()
p1.catch(() => {})
@@ -326,7 +315,7 @@ test("list - returns empty when no pending", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const pending = await list()
const pending = await Question.list()
expect(pending.length).toBe(0)
},
})
@@ -339,7 +328,7 @@ test("questions stay isolated by directory", async () => {
const p1 = Instance.provide({
directory: one.path,
fn: () =>
ask({
Question.ask({
sessionID: SessionID.make("ses_one"),
questions: [
{
@@ -354,7 +343,7 @@ test("questions stay isolated by directory", async () => {
const p2 = Instance.provide({
directory: two.path,
fn: () =>
ask({
Question.ask({
sessionID: SessionID.make("ses_two"),
questions: [
{
@@ -368,11 +357,11 @@ test("questions stay isolated by directory", async () => {
const onePending = await Instance.provide({
directory: one.path,
fn: () => list(),
fn: () => Question.list(),
})
const twoPending = await Instance.provide({
directory: two.path,
fn: () => list(),
fn: () => Question.list(),
})
expect(onePending.length).toBe(1)
@@ -382,11 +371,11 @@ test("questions stay isolated by directory", async () => {
await Instance.provide({
directory: one.path,
fn: () => reject(onePending[0].id),
fn: () => Question.reject(onePending[0].id),
})
await Instance.provide({
directory: two.path,
fn: () => reject(twoPending[0].id),
fn: () => Question.reject(twoPending[0].id),
})
await p1.catch(() => {})
@@ -396,10 +385,10 @@ test("questions stay isolated by directory", async () => {
test("pending question rejects on instance dispose", async () => {
await using tmp = await tmpdir({ git: true })
const pending = Instance.provide({
const ask = Instance.provide({
directory: tmp.path,
fn: () => {
return ask({
return Question.ask({
sessionID: SessionID.make("ses_dispose"),
questions: [
{
@@ -411,7 +400,7 @@ test("pending question rejects on instance dispose", async () => {
})
},
})
const result = pending.then(
const result = ask.then(
() => "resolved" as const,
(err) => err,
)
@@ -419,8 +408,8 @@ test("pending question rejects on instance dispose", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const items = await list()
expect(items).toHaveLength(1)
const pending = await Question.list()
expect(pending).toHaveLength(1)
await Instance.dispose()
},
})
@@ -431,10 +420,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 pending = Instance.provide({
const ask = Instance.provide({
directory: tmp.path,
fn: () => {
return ask({
return Question.ask({
sessionID: SessionID.make("ses_reload"),
questions: [
{
@@ -446,7 +435,7 @@ test("pending question rejects on instance reload", async () => {
})
},
})
const result = pending.then(
const result = ask.then(
() => "resolved" as const,
(err) => err,
)
@@ -454,8 +443,8 @@ test("pending question rejects on instance reload", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const items = await list()
expect(items).toHaveLength(1)
const pending = await Question.list()
expect(pending).toHaveLength(1)
await Instance.reload({ directory: tmp.path })
},
})
@@ -1,9 +1,11 @@
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"
@@ -14,6 +16,25 @@ 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 })
@@ -24,7 +45,9 @@ 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)
@@ -34,4 +57,28 @@ describe("session action routes", () => {
},
})
})
test("delete message route returns 400 when session is busy", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const msg = await user(session.id, "hello")
const busy = spyOn(SessionRunState, "assertNotBusy").mockRejectedValue(new Session.BusyError(session.id))
const remove = spyOn(Session, "removeMessage").mockResolvedValue(msg.id)
const app = Server.Default().app
const res = await app.request(`/session/${session.id}/message/${msg.id}`, {
method: "DELETE",
})
expect(res.status).toBe(400)
expect(busy).toHaveBeenCalledWith(session.id)
expect(remove).not.toHaveBeenCalled()
await Session.remove(session.id)
},
})
})
})
@@ -147,7 +147,7 @@ describe("session messages endpoint", () => {
describe("session.prompt_async error handling", () => {
test("prompt_async route has error handler for detached prompt call", async () => {
const src = await Bun.file(new URL("../../src/server/instance/session.ts", import.meta.url)).text()
const src = await Bun.file(new URL("../../src/server/routes/session.ts", import.meta.url)).text()
const start = src.indexOf('"/:sessionID/prompt_async"')
const end = src.indexOf('"/:sessionID/command"', start)
expect(start).toBeGreaterThan(-1)
@@ -1,6 +1,5 @@
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"
@@ -9,9 +8,6 @@ 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")
@@ -61,22 +57,17 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
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)
fn: async () => {
const system = await Instruction.systemPaths()
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
const results = yield* svc.resolve(
[],
path.join(tmp.path, "src", "file.ts"),
MessageID.make("message-test-1"),
)
expect(results).toEqual([])
}),
),
),
const results = await Instruction.resolve(
[],
path.join(tmp.path, "src", "file.ts"),
MessageID.make("message-test-1"),
)
expect(results).toEqual([])
},
})
})
@@ -89,23 +80,18 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
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)
fn: async () => {
const system = await Instruction.systemPaths()
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
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"))
}),
),
),
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"))
},
})
})
@@ -118,19 +104,14 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
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)
fn: async () => {
const filepath = path.join(tmp.path, "subdir", "AGENTS.md")
const system = await Instruction.systemPaths()
expect(system.has(filepath)).toBe(false)
const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3"))
expect(results).toEqual([])
}),
),
),
const results = await Instruction.resolve([], filepath, MessageID.make("message-test-3"))
expect(results).toEqual([])
},
})
})
@@ -143,22 +124,17 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
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")
fn: async () => {
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-1")
const first = yield* svc.resolve([], filepath, id)
const second = yield* svc.resolve([], filepath, id)
const first = await Instruction.resolve([], filepath, id)
const second = await Instruction.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([])
},
})
})
@@ -171,23 +147,18 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
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")
fn: async () => {
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-2")
const first = yield* svc.resolve([], filepath, id)
yield* svc.clear(id)
const second = yield* svc.resolve([], filepath, id)
const first = await Instruction.resolve([], filepath, id)
await Instruction.clear(id)
const second = await Instruction.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"))
},
})
})
@@ -200,19 +171,15 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
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")
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")
const results = yield* svc.resolve(loaded(agents), filepath, id)
expect(results).toEqual([])
}),
),
),
const results = await Instruction.resolve(loaded(agents), filepath, id)
expect(results).toEqual([])
},
})
})
@@ -254,16 +221,11 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
try {
await Instance.provide({
directory: projectTmp.path,
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)
}),
),
),
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)
},
})
} finally {
;(Global.Path as { config: string }).config = originalGlobalConfig
@@ -286,16 +248,11 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
try {
await Instance.provide({
directory: projectTmp.path,
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)
}),
),
),
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)
},
})
} finally {
;(Global.Path as { config: string }).config = originalGlobalConfig
@@ -317,15 +274,10 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
try {
await Instance.provide({
directory: projectTmp.path,
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)
}),
),
),
fn: async () => {
const paths = await Instruction.systemPaths()
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
},
})
} finally {
;(Global.Path as { config: string }).config = originalGlobalConfig
@@ -31,7 +31,6 @@ import { SessionRunState } from "../../src/session/run-state"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { Skill } from "../../src/skill"
import { SystemPrompt } from "../../src/session/system"
import { Shell } from "../../src/shell/shell"
import { Snapshot } from "../../src/snapshot"
import { ToolRegistry } from "../../src/tool/registry"
@@ -194,7 +193,6 @@ function makeHttp() {
Layer.provideMerge(registry),
Layer.provideMerge(trunc),
Layer.provide(Instruction.defaultLayer),
Layer.provide(SystemPrompt.defaultLayer),
Layer.provideMerge(deps),
),
)
@@ -41,7 +41,6 @@ import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "../../src/provider/provider"
import { Question } from "../../src/question"
import { Skill } from "../../src/skill"
import { SystemPrompt } from "../../src/session/system"
import { Todo } from "../../src/session/todo"
import { SessionCompaction } from "../../src/session/compaction"
import { Instruction } from "../../src/session/instruction"
@@ -158,7 +157,6 @@ function makeHttp() {
Layer.provideMerge(registry),
Layer.provideMerge(trunc),
Layer.provide(Instruction.defaultLayer),
Layer.provide(SystemPrompt.defaultLayer),
Layer.provideMerge(deps),
),
)
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Effect } from "effect"
import { Agent } from "../../src/agent/agent"
import { Instance } from "../../src/project/instance"
import { SystemPrompt } from "../../src/session/system"
@@ -39,13 +38,8 @@ description: ${description}
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const runSkills = Effect.gen(function* () {
const svc = yield* SystemPrompt.Service
return yield* svc.skills(build!)
}).pipe(Effect.provide(SystemPrompt.defaultLayer))
const first = await Effect.runPromise(runSkills)
const second = await Effect.runPromise(runSkills)
const first = await SystemPrompt.skills(build!)
const second = await SystemPrompt.skills(build!)
expect(first).toBe(second)
@@ -511,49 +511,6 @@ 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({
@@ -578,67 +535,6 @@ 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 () => {
// 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")
const patch = await Snapshot.patch(before!)
// a.txt is now gitignored and should NOT appear in patch
expect(patch.files).not.toContain(fwd(tmp.path, "a.txt"))
// .gitignore itself should appear (it's a new file)
expect(patch.files).toContain(fwd(tmp.path, ".gitignore"))
// b.txt should appear (not gitignored)
expect(patch.files).toContain(fwd(tmp.path, "b.txt"))
},
})
})
test("gitignore updated between track calls filters from diff", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.path,
fn: async () => {
// First track - a.txt is tracked in snapshot
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Modify a.txt
await Filesystem.write(`${tmp.path}/a.txt`, "modified content")
// Now add a.txt to gitignore
await Filesystem.write(`${tmp.path}/.gitignore`, "a.txt\n")
// 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)
},
})
})
test("git info exclude changes", async () => {
await using tmp = await bootstrap()
await Instance.provide({
@@ -7,21 +7,12 @@ 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,
Truncate.defaultLayer,
Agent.defaultLayer,
),
Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer),
)
const baseCtx = {
@@ -31,7 +22,7 @@ const baseCtx = {
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
metadata: () => {},
}
type AskInput = {
@@ -59,7 +50,7 @@ type ToolCtx = typeof baseCtx & {
const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
const info = await runtime.runPromise(ApplyPatchTool)
const tool = await runtime.runPromise(info.init())
const tool = await info.init()
return Effect.runPromise(tool.execute(params, ctx))
}
+14 -23
View File
@@ -8,7 +8,6 @@ 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"
@@ -16,17 +15,11 @@ import { AppFileSystem } from "../../src/filesystem"
import { Plugin } from "../../src/plugin"
const runtime = ManagedRuntime.make(
Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer,
Plugin.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, Plugin.defaultLayer),
)
function initBash() {
return runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => info.init())))
return runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init()))))
}
const ctx = {
@@ -36,7 +29,7 @@ const ctx = {
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
metadata: () => {},
ask: () => Effect.void,
}
@@ -989,14 +982,13 @@ describe("tool.bash abort", () => {
{
...ctx,
abort: controller.signal,
metadata: (input) =>
Effect.sync(() => {
const output = (input.metadata as { output?: string })?.output
if (output && output.includes("before") && !controller.signal.aborted) {
collected.push(output)
controller.abort()
}
}),
metadata: (input) => {
const output = (input.metadata as { output?: string })?.output
if (output && output.includes("before") && !controller.signal.aborted) {
collected.push(output)
controller.abort()
}
},
},
),
)
@@ -1082,11 +1074,10 @@ describe("tool.bash abort", () => {
},
{
...ctx,
metadata: (input) =>
Effect.sync(() => {
const output = (input.metadata as { output?: string })?.output
if (output) updates.push(output)
}),
metadata: (input) => {
const output = (input.metadata as { output?: string })?.output
if (output) updates.push(output)
},
},
),
)
+3 -13
View File
@@ -9,10 +9,8 @@ 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 = {
@@ -22,7 +20,7 @@ const ctx = {
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
metadata: () => {},
ask: () => Effect.void,
}
@@ -36,15 +34,7 @@ async function touch(file: string, time: number) {
}
const runtime = ManagedRuntime.make(
Layer.mergeAll(
LSP.defaultLayer,
FileTime.defaultLayer,
AppFileSystem.defaultLayer,
Format.defaultLayer,
Bus.layer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer),
)
afterAll(async () => {
@@ -55,7 +45,7 @@ const resolve = () =>
runtime.runPromise(
Effect.gen(function* () {
const info = yield* EditTool
return yield* info.init()
return yield* Effect.promise(() => info.init())
}),
)
@@ -16,7 +16,7 @@ const baseCtx: Omit<Tool.Context, "ask"> = {
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
metadata: () => {},
}
const glob = (p: string) =>
+3 -7
View File
@@ -6,15 +6,11 @@ import { Instance } from "../../src/project/instance"
import { tmpdir } 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"
const runtime = ManagedRuntime.make(
Layer.mergeAll(CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
)
const runtime = ManagedRuntime.make(Layer.mergeAll(CrossSpawnSpawner.defaultLayer))
function initGrep() {
return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => info.init())))
return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init()))))
}
const ctx = {
@@ -24,7 +20,7 @@ const ctx = {
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
metadata: () => {},
ask: () => Effect.void,
}
+4 -8
View File
@@ -4,9 +4,7 @@ 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"
@@ -17,13 +15,11 @@ const ctx = {
agent: "test-agent",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
metadata: () => {},
ask: () => Effect.void,
}
const it = testEffect(
Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
)
const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer))
const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) {
for (;;) {
@@ -40,7 +36,7 @@ describe("tool.question", () => {
Effect.gen(function* () {
const question = yield* Question.Service
const toolInfo = yield* QuestionTool
const tool = yield* toolInfo.init()
const tool = yield* Effect.promise(() => toolInfo.init())
const questions = [
{
question: "What is your favorite color?",
@@ -68,7 +64,7 @@ describe("tool.question", () => {
Effect.gen(function* () {
const question = yield* Question.Service
const toolInfo = yield* QuestionTool
const tool = yield* toolInfo.init()
const tool = yield* Effect.promise(() => toolInfo.init())
const questions = [
{
question: "What is your favorite animal?",
+2 -4
View File
@@ -11,7 +11,6 @@ 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"
@@ -30,7 +29,7 @@ const ctx = {
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
metadata: () => {},
ask: () => Effect.void,
}
@@ -42,13 +41,12 @@ const it = testEffect(
FileTime.defaultLayer,
Instruction.defaultLayer,
LSP.defaultLayer,
Truncate.defaultLayer,
),
)
const init = Effect.fn("ReadToolTest.init")(function* () {
const info = yield* ReadTool
return yield* info.init()
return yield* Effect.promise(() => info.init())
})
const run = Effect.fn("ReadToolTest.run")(function* (

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