diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 5d4f7fa26..733748462 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -26,6 +26,7 @@ kommander r44vc0rp rekram1-node -robinmordasiewicz +simonklee -spider-yamet clawdbot/llm psychosis, spam pinging the team thdxr -toastythebot diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 276e07748..af008f6b1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -389,6 +389,7 @@ jobs: needs: - build-cli - version + if: github.repository == 'anomalyco/opencode' continue-on-error: false env: AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} @@ -421,7 +422,6 @@ jobs: target: aarch64-unknown-linux-gnu platform_flag: --linux runs-on: ${{ matrix.settings.host }} - # if: github.ref_name == 'beta' steps: - uses: actions/checkout@v3 @@ -547,6 +547,7 @@ jobs: - sign-cli-windows - build-tauri - build-electron + if: always() && !failure() && !cancelled() runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 70a8477fb..510f68254 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,9 @@ permissions: contents: read checks: write +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: unit: name: unit (${{ matrix.settings.name }}) @@ -38,6 +41,11 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Setup Bun uses: ./.github/actions/setup-bun @@ -102,6 +110,11 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Setup Bun uses: ./.github/actions/setup-bun diff --git a/bun.lock b/bun.lock index f68a0371b..6ba94f6bc 100644 --- a/bun.lock +++ b/bun.lock @@ -45,7 +45,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.4.0", + "version": "1.4.3", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -99,7 +99,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.4.0", + "version": "1.4.3", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -133,7 +133,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.4.0", + "version": "1.4.3", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -160,7 +160,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.4.0", + "version": "1.4.3", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -184,7 +184,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.4.0", + "version": "1.4.3", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -208,7 +208,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.4.0", + "version": "1.4.3", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -241,14 +241,8 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.4.0", + "version": "1.4.3", "dependencies": { - "@opencode-ai/app": "workspace:*", - "@opencode-ai/ui": "workspace:*", - "@solid-primitives/i18n": "2.2.1", - "@solid-primitives/storage": "catalog:", - "@solidjs/meta": "catalog:", - "@solidjs/router": "0.15.4", "effect": "catalog:", "electron-context-menu": "4.1.2", "electron-log": "^5", @@ -256,24 +250,41 @@ "electron-updater": "^6", "electron-window-state": "^5.0.3", "marked": "^15", - "solid-js": "catalog:", - "tree-kill": "^1.2.2", }, "devDependencies": { "@actions/artifact": "4.0.0", + "@lydell/node-pty": "catalog:", + "@opencode-ai/app": "workspace:*", + "@opencode-ai/ui": "workspace:*", + "@solid-primitives/i18n": "2.2.1", + "@solid-primitives/storage": "catalog:", + "@solidjs/meta": "catalog:", + "@solidjs/router": "0.15.4", "@types/bun": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", + "@valibot/to-json-schema": "1.6.0", "electron": "40.4.1", "electron-builder": "^26", "electron-vite": "^5", + "solid-js": "catalog:", + "sury": "11.0.0-alpha.4", "typescript": "~5.6.2", "vite": "catalog:", + "zod-openapi": "5.4.6", + }, + "optionalDependencies": { + "@lydell/node-pty-darwin-arm64": "1.2.0-beta.10", + "@lydell/node-pty-darwin-x64": "1.2.0-beta.10", + "@lydell/node-pty-linux-arm64": "1.2.0-beta.10", + "@lydell/node-pty-linux-x64": "1.2.0-beta.10", + "@lydell/node-pty-win32-arm64": "1.2.0-beta.10", + "@lydell/node-pty-win32-x64": "1.2.0-beta.10", }, }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.4.0", + "version": "1.4.3", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -302,7 +313,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.4.0", + "version": "1.4.3", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -370,7 +381,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.4.0", + "version": "1.4.3", "bin": { "opencode": "./bin/opencode", }, @@ -378,7 +389,7 @@ "@actions/core": "1.11.1", "@actions/github": "6.0.1", "@agentclientprotocol/sdk": "0.16.1", - "@ai-sdk/amazon-bedrock": "4.0.83", + "@ai-sdk/amazon-bedrock": "4.0.93", "@ai-sdk/anthropic": "3.0.67", "@ai-sdk/azure": "3.0.49", "@ai-sdk/cerebras": "2.0.41", @@ -390,7 +401,7 @@ "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", "@ai-sdk/openai": "3.0.48", - "@ai-sdk/openai-compatible": "2.0.37", + "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", @@ -400,13 +411,12 @@ "@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", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", - "@lydell/node-pty": "1.2.0-beta.10", + "@lydell/node-pty": "catalog:", "@modelcontextprotocol/sdk": "1.27.1", "@npmcli/arborist": "9.4.0", "@octokit/graphql": "9.0.2", @@ -416,7 +426,7 @@ "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", - "@openrouter/ai-sdk-provider": "2.4.2", + "@openrouter/ai-sdk-provider": "2.5.1", "@opentui/core": "0.1.97", "@opentui/solid": "0.1.97", "@parcel/watcher": "2.5.1", @@ -437,7 +447,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.0.0", + "gitlab-ai-provider": "6.4.2", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -473,7 +483,7 @@ }, "devDependencies": { "@babel/core": "7.28.4", - "@effect/language-service": "0.79.0", + "@effect/language-service": "0.84.2", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/script": "workspace:*", "@parcel/watcher-darwin-arm64": "2.5.1", @@ -508,9 +518,10 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.4.0", + "version": "1.4.3", "dependencies": { "@opencode-ai/sdk": "workspace:*", + "effect": "catalog:", "zod": "catalog:", }, "devDependencies": { @@ -542,7 +553,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.4.0", + "version": "1.4.3", "dependencies": { "cross-spawn": "catalog:", }, @@ -557,7 +568,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.4.0", + "version": "1.4.3", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -592,7 +603,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.4.0", + "version": "1.4.3", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -641,7 +652,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.4.0", + "version": "1.4.3", "dependencies": { "zod": "catalog:", }, @@ -652,7 +663,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.4.0", + "version": "1.4.3", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -702,9 +713,10 @@ }, "catalog": { "@cloudflare/workers-types": "4.20251008.0", - "@effect/platform-node": "4.0.0-beta.43", + "@effect/platform-node": "4.0.0-beta.46", "@hono/zod-validator": "0.4.2", "@kobalte/core": "0.13.11", + "@lydell/node-pty": "1.2.0-beta.10", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", "@pierre/diffs": "1.1.0-beta.18", @@ -722,13 +734,13 @@ "@types/node": "22.13.9", "@types/semver": "7.7.1", "@typescript/native-preview": "7.0.0-dev.20251207.1", - "ai": "6.0.149", + "ai": "6.0.158", "cross-spawn": "7.0.6", "diff": "8.0.2", "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", - "effect": "4.0.0-beta.43", + "effect": "4.0.0-beta.46", "fuzzysort": "3.1.0", "hono": "4.10.7", "hono-openapi": "1.1.2", @@ -767,7 +779,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.83", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DoRpvIWGU/r83UeJAM9L93Lca8Kf/yP5fIhfEOltMPGP/PXrGe0BZaz0maLSRn8djJ6+HzWIsgu5ZI6bZqXEXg=="], + "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="], "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="], @@ -1205,11 +1217,11 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], - "@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="], + "@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="], - "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.43", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.43", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43", "ioredis": "^5.7.0" } }, "sha512-Uq6E1rjaIpjHauzjwoB2HzAg3battYt2Boy8XO50GoHiWCXKE6WapYZ0/AnaBx5v5qg2sOfqpuiLsUf9ZgxOkA=="], + "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.46", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.46", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.46", "ioredis": "^5.7.0" } }, "sha512-6AFRKjJO95dFl5lK/YnJi04uePjQDFi3+K1aXwcz/EfVlRwJ4+lg5O4vbievfKL/hnfcShVp3/eXnNS9tvlMZQ=="], - "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.43", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-A9q0GEb61pYcQ06Dr6gXj1nKlDI3KHsar1sk3qb1ZY+kVSR64tBAylI8zGon23KY+NPtTUj/sEIToB7jc3Qt5w=="], + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.46", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.46" } }, "sha512-Yzci82XbZ1W3tuiownsJawrJZTGeTrTZKLD0uxdBWCBzlVyqDwoSwRwO5qh33DurJj9B7iS8MDf14fpGRBPNGQ=="], "@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="], @@ -1425,8 +1437,6 @@ "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], - "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="], - "@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], @@ -1831,7 +1841,7 @@ "@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"], - "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.4.2", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-uRQZ4da77gru1I7/lNGJhKbqEIY7o/sPsLlbCM97VY9muGDjM/TaJzuwqIviqKTtXLzF0WDj5qBAi6FhxjvlSg=="], + "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.5.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-r1fJL1Cb3gQDa2MpWH/sfx1BsEW0uzlRriJM6eihaKqbtKDmZoBisF32VcVaQYassighX7NGCkF68EsrZA43uQ=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], @@ -2701,6 +2711,8 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="], + "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], @@ -2759,7 +2771,7 @@ "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], - "ai": ["ai@6.0.149", "", { "dependencies": { "@ai-sdk/gateway": "3.0.91", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3asRb/m3ZGH7H4+VTuTgj8eQYJZ9IJUmV0ljLslY92mQp6Zj+NVn4SmFj0TBr2Y/wFBWC3xgn++47tSGOXxdbw=="], + "ai": ["ai@6.0.158", "", { "dependencies": { "@ai-sdk/gateway": "3.0.95", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ=="], "ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="], @@ -3337,7 +3349,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="], + "effect": ["effect@4.0.0-beta.46", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-3f6gXvvUMtEueCRY0tU76Vq2Pej1SAwwE+s0Owd5nD53yS5n4RZhUA1rlCGFuSbQFA225pGy8vO72+lpvu7u5A=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], @@ -3743,7 +3755,7 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "gitlab-ai-provider": ["gitlab-ai-provider@6.0.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": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-683GcJdrer/GhnljkbVcGsndCEhvGB8f9fUdCxQBlkuyt8rzf0G9DpSh+iMBYp9HpcSvYmYG0Qv5ks9dLrNxwQ=="], + "gitlab-ai-provider": ["gitlab-ai-provider@6.4.2", "", { "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": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Wyw6uslCuipBOr/NYwAtpgXEUJj68iJY5aekad2DjePN99JetKVQBqkLgAy9PZp2EA4OuscfRQu9qKIBN/evNw=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], @@ -5359,6 +5371,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "sury": ["sury@11.0.0-alpha.4", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="], + "system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="], "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], @@ -5449,8 +5463,6 @@ "traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="], - "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], - "tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="], "tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="], @@ -5625,6 +5637,8 @@ "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], + "valibot": ["valibot@1.3.1", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg=="], + "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], @@ -5801,6 +5815,8 @@ "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + "zod-openapi": ["zod-openapi@5.4.6", "", { "peerDependencies": { "zod": "^3.25.74 || ^4.0.0" } }, "sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A=="], + "zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="], "zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="], @@ -5825,7 +5841,11 @@ "@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="], - "@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], + "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="], + + "@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="], + + "@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], @@ -6169,10 +6189,6 @@ "@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], - "@gitlab/gitlab-ai-provider/openai": ["openai@6.33.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw=="], - - "@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@gitlab/opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], "@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], @@ -6501,6 +6517,10 @@ "@solidjs/start/vite-plugin-solid": ["vite-plugin-solid@2.11.11", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-YMZCXsLw9kyuvQFEdwLP27fuTQJLmjNoHy90AOJnbRuJ6DwShUxKFo38gdFrWn9v11hnGicKCZEaeI/TFs6JKw=="], + "@standard-community/standard-json/effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="], + + "@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="], + "@storybook/addon-docs/react-dom": ["react-dom@18.2.0", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g=="], "@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -6547,7 +6567,9 @@ "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.91", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-J39Dh6Gyg6HjG3A7OFKnJMp3QyZ3Eex+XDiX8aFBdRwwZm3jGWaMhkCxQPH7yiQ9kRiErZwHXX/Oexx4SyGGGA=="], + "ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.95", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="], + + "ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.83", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DoRpvIWGU/r83UeJAM9L93Lca8Kf/yP5fIhfEOltMPGP/PXrGe0BZaz0maLSRn8djJ6+HzWIsgu5ZI6bZqXEXg=="], "ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.3.3", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-4fVteGkVedc7fGoA9+qJs4tpYwALezMq14m2Sjub3KmyRlksCbK+WJf67NPdGem8+NZrV2tAN42A1NU3+SiV3w=="], @@ -6873,6 +6895,8 @@ "opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FFX4P5Fd6lcQJc2OLngZQkbbJHa0IDDZi087Edb8qRZx6h90krtM61ArbMUL8us/7ZUwojCXnyJ/wQ2Eflx2jQ=="], + "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], + "opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="], "opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="], @@ -7105,8 +7129,6 @@ "@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="], - "@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -7719,6 +7741,10 @@ "@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], + "@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@storybook/addon-docs/react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], @@ -7733,6 +7759,8 @@ "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], + "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -8221,6 +8249,8 @@ "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], + "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], diff --git a/nix/hashes.json b/nix/hashes.json index f592339c2..13bb82201 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-85wpU1oCWbthPleNIOj5d5AOuuYZ6rM7gMLZR6YJ2WU=", - "aarch64-linux": "sha256-C3A56SDQGJquCpIRj2JhIzr4A7N4cc9lxtEjl8bXDeM=", - "aarch64-darwin": "sha256-/Ij3qhGRrcLlMfl9uEacDNnGK5URxhctuQFBW4Njrog=", - "x86_64-darwin": "sha256-10sOPuN4eZ75orw4FI8ztCq1+AKS2e8aAfg3Z6Yn56w=" + "x86_64-linux": "sha256-fNRQYkucjXr1D61HJRScJpDa6+oBdyhgTBxCu+PE2kQ=", + "aarch64-linux": "sha256-V8J6kn2nSdXrplyqi6aIqNlHcVjSxvye+yC/YFO7PF4=", + "aarch64-darwin": "sha256-6cLmUJVUycGALCmslXuloVGBSlFOSHRjsWjx7KOW8rg=", + "x86_64-darwin": "sha256-kcOSO3NFIJh79ylLotG41ovWLQfH5kh1WYFghUu+4HE=" } } diff --git a/package.json b/package.json index d4713f95d..d08bada05 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "packages/slack" ], "catalog": { - "@effect/platform-node": "4.0.0-beta.43", + "@effect/platform-node": "4.0.0-beta.46", "@types/bun": "1.3.11", "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", @@ -47,8 +47,8 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", - "effect": "4.0.0-beta.43", - "ai": "6.0.149", + "effect": "4.0.0-beta.46", + "ai": "6.0.158", "cross-spawn": "7.0.6", "hono": "4.10.7", "hono-openapi": "1.1.2", @@ -71,7 +71,8 @@ "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "solid-js": "1.9.10", - "vite-plugin-solid": "2.11.10" + "vite-plugin-solid": "2.11.10", + "@lydell/node-pty": "1.2.0-beta.10" } }, "devDependencies": { diff --git a/packages/app/e2e/backend.ts b/packages/app/e2e/backend.ts index 9febc4b3f..a03d1d437 100644 --- a/packages/app/e2e/backend.ts +++ b/packages/app/e2e/backend.ts @@ -44,8 +44,12 @@ async function waitForHealth(url: string, probe = "/global/health") { throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`) } +function done(proc: ReturnType) { + return proc.exitCode !== null || proc.signalCode !== null +} + async function waitExit(proc: ReturnType, timeout = 10_000) { - if (proc.exitCode !== null) return + if (done(proc)) return await Promise.race([ new Promise((resolve) => proc.once("exit", () => resolve())), new Promise((resolve) => setTimeout(resolve, timeout)), @@ -123,11 +127,11 @@ export async function startBackend(label: string, input?: { llmUrl?: string }): return { url, async stop() { - if (proc.exitCode === null) { + if (!done(proc)) { proc.kill("SIGTERM") await waitExit(proc) } - if (proc.exitCode === null) { + if (!done(proc)) { proc.kill("SIGKILL") await waitExit(proc) } diff --git a/packages/app/package.json b/packages/app/package.json index ea0f96e83..2ac271df2 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.4.0", + "version": "1.4.3", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index c0715cc94..35fd36cca 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -182,7 +182,6 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { if (checkMode() === "background" || type === "http") return false } }).pipe( - effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0), Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }), Effect.ensuring(Effect.sync(() => setCheckMode("background"))), Effect.runPromise, diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 0a5a7d2d3..c8430d8bb 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -174,6 +174,7 @@ export const Terminal = (props: TerminalProps) => { const auth = server.current?.http const username = auth?.username ?? "opencode" const password = auth?.password ?? "" + const sameOrigin = new URL(url, location.href).origin === location.origin let container!: HTMLDivElement const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) const id = local.pty.id @@ -519,8 +520,12 @@ export const Terminal = (props: TerminalProps) => { next.searchParams.set("directory", directory) next.searchParams.set("cursor", String(seek)) next.protocol = next.protocol === "https:" ? "wss:" : "ws:" - next.username = username - next.password = password + if (!sameOrigin && password) { + next.searchParams.set("auth_token", btoa(`${username}:${password}`)) + // For same-origin requests, let the browser reuse the page's existing auth. + next.username = username + next.password = password + } const socket = new WebSocket(next) socket.binaryType = "arraybuffer" diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 01248e20e..500013c1d 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -14,6 +14,7 @@ import type { import type { State, VcsCache } from "./types" import { trimSessions } from "./session-trim" import { dropSessionCaches } from "./session-cache" +import { diffs as list, message as clean } from "@/utils/diffs" const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) @@ -162,7 +163,7 @@ export function applyDirectoryEvent(input: { } case "session.diff": { const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] } - input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" })) + input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" })) break } case "todo.updated": { @@ -177,7 +178,7 @@ export function applyDirectoryEvent(input: { break } case "message.updated": { - const info = (event.properties as { info: Message }).info + const info = clean((event.properties as { info: Message }).info) const messages = input.store.message[info.sessionID] if (!messages) { input.setStore("message", info.sessionID, [info]) diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index b023e8ddc..fb02a2d2d 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -13,6 +13,7 @@ import { useGlobalSync } from "./global-sync" import { useSDK } from "./sdk" import type { Message, Part } from "@opencode-ai/sdk/v2/client" import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache" +import { diffs as list, message as clean } from "@/utils/diffs" const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) @@ -300,7 +301,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }), ) const items = (messages.data ?? []).filter((x) => !!x?.info?.id) - const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id)) + const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id)) const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) })) const cursor = messages.response.headers.get("x-next-cursor") ?? undefined return { @@ -509,7 +510,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return runInflight(inflightDiff, key, () => retry(() => client.session.diff({ sessionID })).then((diff) => { if (!tracked(directory, sessionID)) return - setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" })) + setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" })) }), ) }, diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index cf50fbe90..eb6a49411 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -58,6 +58,7 @@ import { TerminalPanel } from "@/pages/session/terminal-panel" import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { Identifier } from "@/utils/id" +import { diffs as list } from "@/utils/diffs" import { Persist, persisted } from "@/utils/persist" import { extractPromptFromParts } from "@/utils/prompt" import { same } from "@/utils/same" @@ -430,7 +431,7 @@ export default function Page() { const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const isChildSession = createMemo(() => !!info()?.parentID) - const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) + const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : [])) const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) const hasSessionReview = createMemo(() => sessionCount() > 0) const canReview = createMemo(() => !!sync.project) @@ -611,7 +612,7 @@ export default function Page() { .diff({ mode }) .then((result) => { if (vcsRun.get(mode) !== run) return - setVcs("diff", mode, result.data ?? []) + setVcs("diff", mode, list(result.data)) setVcs("ready", mode, true) }) .catch((error) => { @@ -649,7 +650,7 @@ export default function Page() { return open }, desktopReviewOpen()) - const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? []) + const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs)) const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git") const changesOptions = createMemo(() => { const list: ChangeMode[] = [] @@ -669,15 +670,11 @@ export default function Page() { if (store.changes === "git" || store.changes === "branch") return store.changes }) const reviewDiffs = createMemo(() => { - if (store.changes === "git") return vcs.diff.git - if (store.changes === "branch") return vcs.diff.branch + if (store.changes === "git") return list(vcs.diff.git) + if (store.changes === "branch") return list(vcs.diff.branch) return turnDiffs() }) - const reviewCount = createMemo(() => { - if (store.changes === "git") return vcs.diff.git.length - if (store.changes === "branch") return vcs.diff.branch.length - return turnDiffs().length - }) + const reviewCount = createMemo(() => reviewDiffs().length) const hasReview = createMemo(() => reviewCount() > 0) const reviewReady = createMemo(() => { if (store.changes === "git") return vcs.ready.git diff --git a/packages/app/src/utils/diffs.test.ts b/packages/app/src/utils/diffs.test.ts new file mode 100644 index 000000000..5fbca469b --- /dev/null +++ b/packages/app/src/utils/diffs.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test" +import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" +import type { Message } from "@opencode-ai/sdk/v2/client" +import { diffs, message } from "./diffs" + +const item = { + file: "src/app.ts", + patch: "@@ -1 +1 @@\n-old\n+new\n", + additions: 1, + deletions: 1, + status: "modified", +} satisfies SnapshotFileDiff + +describe("diffs", () => { + test("keeps valid arrays", () => { + expect(diffs([item])).toEqual([item]) + }) + + test("wraps a single diff object", () => { + expect(diffs(item)).toEqual([item]) + }) + + test("reads keyed diff objects", () => { + expect(diffs({ a: item })).toEqual([item]) + }) + + test("drops invalid entries", () => { + expect( + diffs([ + item, + { file: "src/bad.ts", additions: 1, deletions: 1 }, + { patch: item.patch, additions: 1, deletions: 1 }, + ]), + ).toEqual([item]) + }) +}) + +describe("message", () => { + test("normalizes user summaries with object diffs", () => { + const input = { + id: "msg_1", + sessionID: "ses_1", + role: "user", + time: { created: 1 }, + agent: "build", + model: { providerID: "openai", modelID: "gpt-5" }, + summary: { + title: "Edit", + diffs: { a: item }, + }, + } as unknown as Message + + expect(message(input)).toMatchObject({ + summary: { + title: "Edit", + diffs: [item], + }, + }) + }) + + test("drops invalid user summaries", () => { + const input = { + id: "msg_1", + sessionID: "ses_1", + role: "user", + time: { created: 1 }, + agent: "build", + model: { providerID: "openai", modelID: "gpt-5" }, + summary: true, + } as unknown as Message + + expect(message(input)).toMatchObject({ summary: undefined }) + }) +}) diff --git a/packages/app/src/utils/diffs.ts b/packages/app/src/utils/diffs.ts new file mode 100644 index 000000000..0cb2504fb --- /dev/null +++ b/packages/app/src/utils/diffs.ts @@ -0,0 +1,49 @@ +import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { Message } from "@opencode-ai/sdk/v2/client" + +type Diff = SnapshotFileDiff | VcsFileDiff + +function diff(value: unknown): value is Diff { + if (!value || typeof value !== "object" || Array.isArray(value)) return false + if (!("file" in value) || typeof value.file !== "string") return false + if (!("patch" in value) || typeof value.patch !== "string") return false + if (!("additions" in value) || typeof value.additions !== "number") return false + if (!("deletions" in value) || typeof value.deletions !== "number") return false + if (!("status" in value) || value.status === undefined) return true + return value.status === "added" || value.status === "deleted" || value.status === "modified" +} + +function object(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +export function diffs(value: unknown): Diff[] { + if (Array.isArray(value) && value.every(diff)) return value + if (Array.isArray(value)) return value.filter(diff) + if (diff(value)) return [value] + if (!object(value)) return [] + return Object.values(value).filter(diff) +} + +export function message(value: Message): Message { + if (value.role !== "user") return value + + const raw = value.summary as unknown + if (raw === undefined) return value + if (!object(raw)) return { ...value, summary: undefined } + + const title = typeof raw.title === "string" ? raw.title : undefined + const body = typeof raw.body === "string" ? raw.body : undefined + const next = diffs(raw.diffs) + + if (title === raw.title && body === raw.body && next === raw.diffs) return value + + return { + ...value, + summary: { + ...(title === undefined ? {} : { title }), + ...(body === undefined ? {} : { body }), + diffs: next, + }, + } +} diff --git a/packages/console/app/package.json b/packages/console/app/package.json index e13d72b60..bcb02a907 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.4.0", + "version": "1.4.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/app/src/routes/download/index.css b/packages/console/app/src/routes/download/index.css index 705302616..b2176c34a 100644 --- a/packages/console/app/src/routes/download/index.css +++ b/packages/console/app/src/routes/download/index.css @@ -316,7 +316,8 @@ /* Download Hero Section */ [data-component="download-hero"] { - display: grid; + /* display: grid; */ + display: none; grid-template-columns: 260px 1fr; gap: 4rem; padding-bottom: 2rem; diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 27bbe6505..e60da9d45 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.4.0", + "version": "1.4.3", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 883240318..d6ad86b8f 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.4.0", + "version": "1.4.3", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 4427d18c9..898387d01 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.4.0", + "version": "1.4.3", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop-electron/electron-builder.config.ts b/packages/desktop-electron/electron-builder.config.ts index 70441d8d7..b3fcd1708 100644 --- a/packages/desktop-electron/electron-builder.config.ts +++ b/packages/desktop-electron/electron-builder.config.ts @@ -34,11 +34,6 @@ const getBase = (): Configuration => ({ }, files: ["out/**/*", "resources/**/*"], extraResources: [ - { - from: "resources/", - to: "", - filter: ["opencode-cli*"], - }, { from: "native/", to: "native/", diff --git a/packages/desktop-electron/electron.vite.config.ts b/packages/desktop-electron/electron.vite.config.ts index 6903d5ed2..e2b296a3e 100644 --- a/packages/desktop-electron/electron.vite.config.ts +++ b/packages/desktop-electron/electron.vite.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from "electron-vite" import appPlugin from "@opencode-ai/app/vite" +import * as fs from "node:fs/promises" const channel = (() => { const raw = process.env.OPENCODE_CHANNEL @@ -7,6 +8,10 @@ const channel = (() => { return "dev" })() +const OPENCODE_SERVER_DIST = "../opencode/dist/node" + +const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}` + export default defineConfig({ main: { define: { @@ -16,7 +21,33 @@ export default defineConfig({ rollupOptions: { input: { index: "src/main/index.ts" }, }, + externalizeDeps: { include: [nodePtyPkg] }, }, + plugins: [ + { + name: "opencode:node-pty-narrower", + enforce: "pre", + resolveId(s) { + if (s === "@lydell/node-pty") return nodePtyPkg + }, + }, + { + name: "opencode:virtual-server-module", + enforce: "pre", + resolveId(id) { + if (id === "virtual:opencode-server") return this.resolve(`${OPENCODE_SERVER_DIST}/node.js`) + }, + }, + { + name: "opencode:copy-server-assets", + async writeBundle() { + for (const l of await fs.readdir(OPENCODE_SERVER_DIST)) { + if (!l.endsWith(".wasm")) continue + await fs.writeFile(`./out/main/chunks/${l}`, await fs.readFile(`${OPENCODE_SERVER_DIST}/${l}`)) + } + }, + }, + ], }, preload: { build: { diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 66d2144d5..694382045 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.4.0", + "version": "1.4.3", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", @@ -13,7 +13,7 @@ "typecheck": "tsgo -b", "predev": "bun ./scripts/predev.ts", "dev": "electron-vite dev", - "prebuild": "bun ./scripts/copy-icons.ts", + "prebuild": "bun ./scripts/prebuild.ts", "build": "electron-vite build", "preview": "electron-vite preview", "package": "electron-builder --config electron-builder.config.ts", @@ -24,31 +24,42 @@ }, "main": "./out/main/index.js", "dependencies": { - "@opencode-ai/app": "workspace:*", - "@opencode-ai/ui": "workspace:*", - "@solid-primitives/i18n": "2.2.1", - "@solid-primitives/storage": "catalog:", - "@solidjs/meta": "catalog:", - "@solidjs/router": "0.15.4", "effect": "catalog:", "electron-context-menu": "4.1.2", "electron-log": "^5", "electron-store": "^10", "electron-updater": "^6", "electron-window-state": "^5.0.3", - "marked": "^15", - "solid-js": "catalog:", - "tree-kill": "^1.2.2" + "marked": "^15" }, "devDependencies": { "@actions/artifact": "4.0.0", + "@lydell/node-pty": "catalog:", + "@opencode-ai/app": "workspace:*", + "@opencode-ai/ui": "workspace:*", + "@solid-primitives/i18n": "2.2.1", + "@solid-primitives/storage": "catalog:", + "@solidjs/meta": "catalog:", + "@solidjs/router": "0.15.4", "@types/bun": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", + "@valibot/to-json-schema": "1.6.0", "electron": "40.4.1", "electron-builder": "^26", "electron-vite": "^5", + "solid-js": "catalog:", + "sury": "11.0.0-alpha.4", "typescript": "~5.6.2", - "vite": "catalog:" + "vite": "catalog:", + "zod-openapi": "5.4.6" + }, + "optionalDependencies": { + "@lydell/node-pty-darwin-arm64": "1.2.0-beta.10", + "@lydell/node-pty-darwin-x64": "1.2.0-beta.10", + "@lydell/node-pty-linux-arm64": "1.2.0-beta.10", + "@lydell/node-pty-linux-x64": "1.2.0-beta.10", + "@lydell/node-pty-win32-arm64": "1.2.0-beta.10", + "@lydell/node-pty-win32-x64": "1.2.0-beta.10" } } diff --git a/packages/desktop-electron/scripts/prebuild.ts b/packages/desktop-electron/scripts/prebuild.ts new file mode 100644 index 000000000..46a2475ea --- /dev/null +++ b/packages/desktop-electron/scripts/prebuild.ts @@ -0,0 +1,9 @@ +#!/usr/bin/env bun +import { $ } from "bun" + +import { resolveChannel } from "./utils" + +const channel = resolveChannel() +await $`bun ./scripts/copy-icons.ts ${channel}` + +await $`cd ../opencode && bun script/build-node.ts` diff --git a/packages/desktop-electron/scripts/predev.ts b/packages/desktop-electron/scripts/predev.ts index a688d0e7f..37c31d7ee 100644 --- a/packages/desktop-electron/scripts/predev.ts +++ b/packages/desktop-electron/scripts/predev.ts @@ -1,17 +1,5 @@ import { $ } from "bun" -import { copyBinaryToSidecarFolder, getCurrentSidecar, windowsify } from "./utils" - await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}` -const RUST_TARGET = Bun.env.RUST_TARGET - -const sidecarConfig = getCurrentSidecar(RUST_TARGET) - -const binaryPath = windowsify(`../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`) - -await (sidecarConfig.ocBinary.includes("-baseline") - ? $`cd ../opencode && bun run build --single --baseline` - : $`cd ../opencode && bun run build --single`) - -await copyBinaryToSidecarFolder(binaryPath, RUST_TARGET) +await $`cd ../opencode && bun script/build-node.ts` diff --git a/packages/desktop-electron/scripts/prepare.ts b/packages/desktop-electron/scripts/prepare.ts index 3704b2e61..0dfd5a35c 100755 --- a/packages/desktop-electron/scripts/prepare.ts +++ b/packages/desktop-electron/scripts/prepare.ts @@ -1,25 +1,9 @@ #!/usr/bin/env bun -import { $ } from "bun" - import { Script } from "@opencode-ai/script" -import { copyBinaryToSidecarFolder, getCurrentSidecar, resolveChannel, windowsify } from "./utils" -const channel = resolveChannel() -await $`bun ./scripts/copy-icons.ts ${channel}` +await import("./prebuild") const pkg = await Bun.file("./package.json").json() pkg.version = Script.version await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n") console.log(`Updated package.json version to ${Script.version}`) - -const sidecarConfig = getCurrentSidecar() -const artifact = process.env.OPENCODE_CLI_ARTIFACT ?? "opencode-cli" - -const dir = "resources/opencode-binaries" - -await $`mkdir -p ${dir}` -await $`gh run download ${process.env.GITHUB_RUN_ID} -n ${artifact}`.cwd(dir) - -await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`)) - -await $`rm -rf ${dir}` diff --git a/packages/desktop-electron/src/main/cli.ts b/packages/desktop-electron/src/main/cli.ts deleted file mode 100644 index ebaf89fda..000000000 --- a/packages/desktop-electron/src/main/cli.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { execFileSync, spawn } from "node:child_process" -import { EventEmitter } from "node:events" -import { chmodSync, readFileSync, unlinkSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import { dirname, join } from "node:path" -import readline from "node:readline" -import { fileURLToPath } from "node:url" -import { app } from "electron" -import treeKill from "tree-kill" - -import { WSL_ENABLED_KEY } from "./constants" -import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env" -import { store } from "./store" - -const CLI_INSTALL_DIR = ".opencode/bin" -const CLI_BINARY_NAME = "opencode" - -export type ServerConfig = { - hostname?: string - port?: number -} - -export type Config = { - server?: ServerConfig -} - -export type TerminatedPayload = { code: number | null; signal: number | null } - -export type CommandEvent = - | { type: "stdout"; value: string } - | { type: "stderr"; value: string } - | { type: "error"; value: string } - | { type: "terminated"; value: TerminatedPayload } - | { type: "sqlite"; value: SqliteMigrationProgress } - -export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" } - -export type CommandChild = { - pid: number | undefined - kill: () => void -} - -const root = dirname(fileURLToPath(import.meta.url)) - -export function getSidecarPath() { - const suffix = process.platform === "win32" ? ".exe" : "" - const path = app.isPackaged - ? join(process.resourcesPath, `opencode-cli${suffix}`) - : join(root, "../../resources", `opencode-cli${suffix}`) - console.log(`[cli] Sidecar path resolved: ${path} (isPackaged: ${app.isPackaged})`) - return path -} - -export async function getConfig(): Promise { - const { events } = spawnCommand("debug config", {}) - let output = "" - - await new Promise((resolve) => { - events.on("stdout", (line: string) => { - output += line - }) - events.on("stderr", (line: string) => { - output += line - }) - events.on("terminated", () => resolve()) - events.on("error", () => resolve()) - }) - - try { - return JSON.parse(output) as Config - } catch { - return null - } -} - -export async function installCli(): Promise { - if (process.platform === "win32") { - throw new Error("CLI installation is only supported on macOS & Linux") - } - - const sidecar = getSidecarPath() - const scriptPath = join(app.getAppPath(), "install") - const script = readFileSync(scriptPath, "utf8") - const tempScript = join(tmpdir(), "opencode-install.sh") - - writeFileSync(tempScript, script, "utf8") - chmodSync(tempScript, 0o755) - - const cmd = spawn(tempScript, ["--binary", sidecar], { stdio: "pipe" }) - return await new Promise((resolve, reject) => { - cmd.on("exit", (code: number | null) => { - try { - unlinkSync(tempScript) - } catch {} - if (code === 0) { - const installPath = getCliInstallPath() - if (installPath) return resolve(installPath) - return reject(new Error("Could not determine install path")) - } - reject(new Error("Install script failed")) - }) - }) -} - -export function syncCli() { - if (!app.isPackaged) return - const installPath = getCliInstallPath() - if (!installPath) return - - let version = "" - try { - version = execFileSync(installPath, ["--version"], { windowsHide: true }).toString().trim() - } catch { - return - } - - const cli = parseVersion(version) - const appVersion = parseVersion(app.getVersion()) - if (!cli || !appVersion) return - if (compareVersions(cli, appVersion) >= 0) return - void installCli().catch(() => undefined) -} - -export function serve(hostname: string, port: number, password: string) { - const args = `--print-logs --log-level WARN serve --hostname ${hostname} --port ${port}` - const env = { - OPENCODE_SERVER_USERNAME: "opencode", - OPENCODE_SERVER_PASSWORD: password, - } - - return spawnCommand(args, env) -} - -export function spawnCommand(args: string, extraEnv: Record) { - console.log(`[cli] Spawning command with args: ${args}`) - const base = Object.fromEntries( - Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"), - ) - const env = { - ...base, - OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", - OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", - OPENCODE_CLIENT: "desktop", - XDG_STATE_HOME: app.getPath("userData"), - ...extraEnv, - } - const shell = process.platform === "win32" ? null : getUserShell() - const envs = shell ? mergeShellEnv(loadShellEnv(shell), env) : env - - const { cmd, cmdArgs } = buildCommand(args, envs, shell) - console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`) - const child = spawn(cmd, cmdArgs, { - env: envs, - detached: process.platform !== "win32", - windowsHide: true, - stdio: ["ignore", "pipe", "pipe"], - }) - console.log(`[cli] Spawned process with PID: ${child.pid}`) - - const events = new EventEmitter() - const exit = new Promise((resolve) => { - child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => { - console.log(`[cli] Process exited with code: ${code}, signal: ${signal}`) - resolve({ code: code ?? null, signal: null }) - }) - child.on("error", (error: Error) => { - console.error(`[cli] Process error: ${error.message}`) - events.emit("error", error.message) - }) - }) - - const stdout = child.stdout - const stderr = child.stderr - - if (stdout) { - readline.createInterface({ input: stdout }).on("line", (line: string) => { - if (handleSqliteProgress(events, line)) return - events.emit("stdout", `${line}\n`) - }) - } - - if (stderr) { - readline.createInterface({ input: stderr }).on("line", (line: string) => { - if (handleSqliteProgress(events, line)) return - events.emit("stderr", `${line}\n`) - }) - } - - exit.then((payload) => { - events.emit("terminated", payload) - }) - - const kill = () => { - if (!child.pid) return - treeKill(child.pid) - } - - return { events, child: { pid: child.pid, kill }, exit } -} - -function handleSqliteProgress(events: EventEmitter, line: string) { - const stripped = line.startsWith("sqlite-migration:") ? line.slice("sqlite-migration:".length).trim() : null - if (!stripped) return false - if (stripped === "done") { - events.emit("sqlite", { type: "Done" }) - return true - } - const value = Number.parseInt(stripped, 10) - if (!Number.isNaN(value)) { - events.emit("sqlite", { type: "InProgress", value }) - return true - } - return false -} - -function buildCommand(args: string, env: Record, shell: string | null) { - if (process.platform === "win32" && isWslEnabled()) { - console.log(`[cli] Using WSL mode`) - const version = app.getVersion() - const script = [ - "set -e", - 'BIN="$HOME/.opencode/bin/opencode"', - 'if [ ! -x "$BIN" ]; then', - ` curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)} --no-modify-path`, - "fi", - `${envPrefix(env)} exec "$BIN" ${args}`, - ].join("\n") - - return { cmd: "wsl", cmdArgs: ["-e", "bash", "-lc", script] } - } - - if (process.platform === "win32") { - const sidecar = getSidecarPath() - console.log(`[cli] Windows direct mode, sidecar: ${sidecar}`) - return { cmd: sidecar, cmdArgs: args.split(" ") } - } - - const sidecar = getSidecarPath() - const user = shell || getUserShell() - const line = user.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}` - console.log(`[cli] Unix mode, shell: ${user}, command: ${line}`) - return { cmd: user, cmdArgs: ["-l", "-c", line] } -} - -function envPrefix(env: Record) { - const entries = Object.entries(env).map(([key, value]) => `${key}=${shellEscape(value)}`) - return entries.join(" ") -} - -function shellEscape(input: string) { - if (!input) return "''" - return `'${input.replace(/'/g, `'"'"'`)}'` -} - -function getCliInstallPath() { - const home = process.env.HOME - if (!home) return null - return join(home, CLI_INSTALL_DIR, CLI_BINARY_NAME) -} - -function isWslEnabled() { - return store.get(WSL_ENABLED_KEY) === true -} - -function parseVersion(value: string) { - const parts = value - .replace(/^v/, "") - .split(".") - .map((part) => Number.parseInt(part, 10)) - if (parts.some((part) => Number.isNaN(part))) return null - return parts -} - -function compareVersions(a: number[], b: number[]) { - const len = Math.max(a.length, b.length) - for (let i = 0; i < len; i += 1) { - const left = a[i] ?? 0 - const right = b[i] ?? 0 - if (left > right) return 1 - if (left < right) return -1 - } - return 0 -} diff --git a/packages/desktop-electron/src/main/env.d.ts b/packages/desktop-electron/src/main/env.d.ts index 0ee0c551d..1de56e1c9 100644 --- a/packages/desktop-electron/src/main/env.d.ts +++ b/packages/desktop-electron/src/main/env.d.ts @@ -5,3 +5,25 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv } +declare module "virtual:opencode-server" { + export namespace Server { + export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen + export type Listener = import("../../../opencode/dist/types/src/node").Server.Listener + } + export namespace Config { + export const get: typeof import("../../../opencode/dist/types/src/node").Config.get + export type Info = import("../../../opencode/dist/types/src/node").Config.Info + } + export namespace Log { + export const init: typeof import("../../../opencode/dist/types/src/node").Log.init + } + export namespace Database { + export const Path: typeof import("../../../opencode/dist/types/src/node").Database.Path + export const Client: typeof import("../../../opencode/dist/types/src/node").Database.Client + } + export namespace JsonMigration { + export type Progress = import("../../../opencode/dist/types/src/node").JsonMigration.Progress + export const run: typeof import("../../../opencode/dist/types/src/node").JsonMigration.run + } + export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap +} diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index b635caa4e..89e7c61ac 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -11,6 +11,8 @@ import pkg from "electron-updater" import contextMenu from "electron-context-menu" contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false }) +process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true" + const APP_NAMES: Record = { dev: "OpenCode Dev", beta: "OpenCode Beta", @@ -27,8 +29,6 @@ const { autoUpdater } = pkg import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" import { checkAppExists, resolveAppPath, wslPath } from "./apps" -import type { CommandChild } from "./cli" -import { installCli, syncCli } from "./cli" import { CHANNEL, UPDATER_ENABLED } from "./constants" import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc" import { initLogging } from "./logging" @@ -36,12 +36,13 @@ import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows" +import type { Server } from "virtual:opencode-server" const initEmitter = new EventEmitter() let initStep: InitStep = { phase: "server_waiting" } let mainWindow: BrowserWindow | null = null -let sidecar: CommandChild | null = null +let server: Server.Listener | null = null const loadingComplete = defer() const pendingDeepLinks: string[] = [] @@ -96,11 +97,9 @@ function setupApp() { } void app.whenReady().then(async () => { - // migrate() app.setAsDefaultProtocolClient("opencode") setDockIcon() setupAutoUpdater() - syncCli() await initialize() }) } @@ -134,8 +133,8 @@ async function initialize() { const password = randomUUID() logger.log("spawning sidecar", { url }) - const { child, health, events } = spawnLocalServer(hostname, port, password) - sidecar = child + const { listener, health } = await spawnLocalServer(hostname, port, password) + server = listener serverReady.resolve({ url, username: "opencode", @@ -145,7 +144,7 @@ async function initialize() { const loadingTask = (async () => { logger.log("sidecar connection started", { url }) - events.on("sqlite", (progress: SqliteMigrationProgress) => { + initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => { setInitStep({ phase: "sqlite_waiting" }) if (overlay) sendSqliteMigrationProgress(overlay, progress) if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) @@ -198,9 +197,6 @@ function wireMenu() { if (!mainWindow) return createMenu({ trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id), - installCli: () => { - void installCli() - }, checkForUpdates: () => { void checkForUpdates(true) }, @@ -215,7 +211,6 @@ function wireMenu() { registerIpcHandlers({ killSidecar: () => killSidecar(), - installCli: async () => installCli(), awaitInitialization: async (sendStep) => { sendStep(initStep) const listener = (step: InitStep) => sendStep(step) @@ -247,16 +242,9 @@ registerIpcHandlers({ }) function killSidecar() { - if (!sidecar) return - const pid = sidecar.pid - sidecar.kill() - sidecar = null - // tree-kill is async; also send process group signal as immediate fallback - if (pid && process.platform !== "win32") { - try { - process.kill(-pid, "SIGTERM") - } catch {} - } + if (!server) return + server.stop() + server = null } function ensureLoopbackNoProxy() { diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index d2cfc2524..52d87ed7e 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -13,7 +13,6 @@ const pickerFilters = (ext?: string[]) => { type Deps = { killSidecar: () => void - installCli: () => Promise awaitInitialization: (sendStep: (step: InitStep) => void) => Promise getDefaultServerUrl: () => Promise | string | null setDefaultServerUrl: (url: string | null) => Promise | void @@ -34,7 +33,6 @@ type Deps = { export function registerIpcHandlers(deps: Deps) { ipcMain.handle("kill-sidecar", () => deps.killSidecar()) - ipcMain.handle("install-cli", () => deps.installCli()) ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => { const send = (step: InitStep) => event.sender.send("init-step", step) return deps.awaitInitialization(send) diff --git a/packages/desktop-electron/src/main/menu.ts b/packages/desktop-electron/src/main/menu.ts index 12e2445bc..fcf209fb6 100644 --- a/packages/desktop-electron/src/main/menu.ts +++ b/packages/desktop-electron/src/main/menu.ts @@ -5,7 +5,6 @@ import { createMainWindow } from "./windows" type Deps = { trigger: (id: string) => void - installCli: () => void checkForUpdates: () => void reload: () => void relaunch: () => void @@ -24,10 +23,6 @@ export function createMenu(deps: Deps) { enabled: UPDATER_ENABLED, click: () => deps.checkForUpdates(), }, - { - label: "Install CLI...", - click: () => deps.installCli(), - }, { label: "Reload Webview", click: () => deps.reload(), diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index 2d09d119f..5a6050013 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -1,5 +1,6 @@ -import { serve, type CommandChild } from "./cli" +import { app } from "electron" import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants" +import { getUserShell, loadShellEnv } from "./shell-env" import { store } from "./store" export type WslConfig = { enabled: boolean } @@ -29,8 +30,16 @@ export function setWslConfig(config: WslConfig) { store.set(WSL_ENABLED_KEY, config.enabled) } -export function spawnLocalServer(hostname: string, port: number, password: string) { - const { child, exit, events } = serve(hostname, port, password) +export async function spawnLocalServer(hostname: string, port: number, password: string) { + prepareServerEnv(password) + const { Log, Server } = await import("virtual:opencode-server") + await Log.init({ level: "WARN" }) + const listener = await Server.listen({ + port, + hostname, + username: "opencode", + password, + }) const wait = (async () => { const url = `http://${hostname}:${port}` @@ -42,19 +51,26 @@ export function spawnLocalServer(hostname: string, port: number, password: strin } } - const terminated = async () => { - const payload = await exit - throw new Error( - `Sidecar terminated before becoming healthy (code=${payload.code ?? "unknown"} signal=${ - payload.signal ?? "unknown" - })`, - ) - } - - await Promise.race([ready(), terminated()]) + await ready() })() - return { child, health: { wait }, events } + return { listener, health: { wait } } +} + +function prepareServerEnv(password: string) { + const shell = process.platform === "win32" ? null : getUserShell() + const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {} + const env = { + ...process.env, + ...shellEnv, + OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", + OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", + OPENCODE_CLIENT: "desktop", + OPENCODE_SERVER_USERNAME: "opencode", + OPENCODE_SERVER_PASSWORD: password, + XDG_STATE_HOME: app.getPath("userData"), + } + Object.assign(process.env, env) } export async function checkHealth(url: string, password?: string | null): Promise { @@ -82,5 +98,3 @@ export async function checkHealth(url: string, password?: string | null): Promis return false } } - -export type { CommandChild } diff --git a/packages/desktop-electron/src/main/shell-env.ts b/packages/desktop-electron/src/main/shell-env.ts index 300084821..8453a5730 100644 --- a/packages/desktop-electron/src/main/shell-env.ts +++ b/packages/desktop-electron/src/main/shell-env.ts @@ -1,7 +1,7 @@ import { spawnSync } from "node:child_process" import { basename } from "node:path" -const SHELL_ENV_TIMEOUT = 5_000 +const TIMEOUT = 5_000 type Probe = { type: "Loaded"; value: Record } | { type: "Timeout" } | { type: "Unavailable" } @@ -20,28 +20,28 @@ export function parseShellEnv(out: Buffer) { return env } -function probeShellEnv(shell: string, mode: "-il" | "-l"): Probe { +function probe(shell: string, mode: "-il" | "-l"): Probe { const out = spawnSync(shell, [mode, "-c", "env -0"], { stdio: ["ignore", "pipe", "ignore"], - timeout: SHELL_ENV_TIMEOUT, + timeout: TIMEOUT, windowsHide: true, }) const err = out.error as NodeJS.ErrnoException | undefined if (err) { if (err.code === "ETIMEDOUT") return { type: "Timeout" } - console.log(`[cli] Shell env probe failed for ${shell} ${mode}: ${err.message}`) + console.log(`[server] Shell env probe failed for ${shell} ${mode}: ${err.message}`) return { type: "Unavailable" } } if (out.status !== 0) { - console.log(`[cli] Shell env probe exited with non-zero status for ${shell} ${mode}`) + console.log(`[server] Shell env probe exited with non-zero status for ${shell} ${mode}`) return { type: "Unavailable" } } const env = parseShellEnv(out.stdout) if (Object.keys(env).length === 0) { - console.log(`[cli] Shell env probe returned empty env for ${shell} ${mode}`) + console.log(`[server] Shell env probe returned empty env for ${shell} ${mode}`) return { type: "Unavailable" } } @@ -56,27 +56,27 @@ export function isNushell(shell: string) { export function loadShellEnv(shell: string) { if (isNushell(shell)) { - console.log(`[cli] Skipping shell env probe for nushell: ${shell}`) + console.log(`[server] Skipping shell env probe for nushell: ${shell}`) return null } - const interactive = probeShellEnv(shell, "-il") + const interactive = probe(shell, "-il") if (interactive.type === "Loaded") { - console.log(`[cli] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`) + console.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`) return interactive.value } if (interactive.type === "Timeout") { - console.warn(`[cli] Interactive shell env probe timed out: ${shell}`) + console.warn(`[server] Interactive shell env probe timed out: ${shell}`) return null } - const login = probeShellEnv(shell, "-l") + const login = probe(shell, "-l") if (login.type === "Loaded") { - console.log(`[cli] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`) + console.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`) return login.value } - console.warn(`[cli] Falling back to app environment: ${shell}`) + console.warn(`[server] Falling back to app environment: ${shell}`) return null } diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 509661e02..8815bf7bc 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.4.0", + "version": "1.4.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/desktop/scripts/finalize-latest-json.ts b/packages/desktop/scripts/finalize-latest-json.ts index a2b95d2c4..855c6a387 100644 --- a/packages/desktop/scripts/finalize-latest-json.ts +++ b/packages/desktop/scripts/finalize-latest-json.ts @@ -21,7 +21,7 @@ const releaseId = process.env.OPENCODE_RELEASE if (!releaseId) throw new Error("OPENCODE_RELEASE is required") const version = process.env.OPENCODE_VERSION -if (!releaseId) throw new Error("OPENCODE_VERSION is required") +if (!version) throw new Error("OPENCODE_VERSION is required") const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required") @@ -54,7 +54,10 @@ const assets = release.assets ?? [] const assetByName = new Map(assets.map((asset) => [asset.name, asset])) const latestAsset = assetByName.get("latest.json") -if (!latestAsset) throw new Error("latest.json asset not found") +if (!latestAsset) { + console.log("latest.json not found, skipping tauri finalization") + process.exit(0) +} const latestRes = await fetch(latestAsset.url, { headers: { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 2f6ebc9f1..db3da877b 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.4.0", + "version": "1.4.3", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 5562adb4b..c13d6c1eb 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.4.0" +version = "1.4.3" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.0/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.0/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.0/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.0/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.0/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index f1996f63b..76262c25a 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.4.0", + "version": "1.4.3", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 95122fa71..36039d3ae 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.4.0", + "version": "1.4.3", "name": "opencode", "type": "module", "license": "MIT", @@ -14,18 +14,11 @@ "fix-node-pty": "bun run script/fix-node-pty.ts", "upgrade-opentui": "bun run script/upgrade-opentui.ts", "dev": "bun run --conditions=browser ./src/index.ts", - "random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'", - "clean": "echo 'Cleaning up...' && rm -rf node_modules dist", - "lint": "echo 'Running lint checks...' && bun test --coverage", - "format": "echo 'Formatting code...' && bun run --prettier --write src/**/*.ts", - "docs": "echo 'Generating documentation...' && find src -name '*.ts' -exec echo 'Processing: {}' \\;", - "deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'", "db": "bun drizzle-kit" }, "bin": { "opencode": "./bin/opencode" }, - "randomField": "this-is-a-random-value-12345", "exports": { "./*": "./src/*.ts" }, @@ -39,11 +32,16 @@ "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": { "@babel/core": "7.28.4", - "@effect/language-service": "0.79.0", + "@effect/language-service": "0.84.2", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/script": "workspace:*", "@parcel/watcher-darwin-arm64": "2.5.1", @@ -79,7 +77,7 @@ "@actions/core": "1.11.1", "@actions/github": "6.0.1", "@agentclientprotocol/sdk": "0.16.1", - "@ai-sdk/amazon-bedrock": "4.0.83", + "@ai-sdk/amazon-bedrock": "4.0.93", "@ai-sdk/anthropic": "3.0.67", "@ai-sdk/azure": "3.0.49", "@ai-sdk/cerebras": "2.0.41", @@ -91,7 +89,7 @@ "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", "@ai-sdk/openai": "3.0.48", - "@ai-sdk/openai-compatible": "2.0.37", + "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", @@ -101,13 +99,12 @@ "@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", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", - "@lydell/node-pty": "1.2.0-beta.10", + "@lydell/node-pty": "catalog:", "@modelcontextprotocol/sdk": "1.27.1", "@npmcli/arborist": "9.4.0", "@octokit/graphql": "9.0.2", @@ -117,7 +114,7 @@ "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", - "@openrouter/ai-sdk-provider": "2.4.2", + "@openrouter/ai-sdk-provider": "2.5.1", "@opentui/core": "0.1.97", "@opentui/solid": "0.1.97", "@parcel/watcher": "2.5.1", @@ -138,7 +135,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.0.0", + "gitlab-ai-provider": "6.4.2", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", diff --git a/packages/opencode/script/build-node.ts b/packages/opencode/script/build-node.ts index 0e1d5bcf4..6c773b086 100755 --- a/packages/opencode/script/build-node.ts +++ b/packages/opencode/script/build-node.ts @@ -1,6 +1,5 @@ #!/usr/bin/env bun -import { $ } from "bun" import { Script } from "@opencode-ai/script" import fs from "fs" import path from "path" @@ -9,18 +8,11 @@ import { fileURLToPath } from "url" const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const dir = path.resolve(__dirname, "..") -const root = path.resolve(dir, "../..") - -function linker(): "hoisted" | "isolated" { - // jsonc-parser is only declared in packages/opencode, so its install location - // tells us whether Bun used a hoisted or isolated workspace layout. - if (fs.existsSync(path.join(dir, "node_modules", "jsonc-parser"))) return "isolated" - if (fs.existsSync(path.join(root, "node_modules", "jsonc-parser"))) return "hoisted" - throw new Error("Could not detect Bun linker from jsonc-parser") -} process.chdir(dir) +await import("./generate.ts") + // Load migrations from migration directories const migrationDirs = ( await fs.promises.readdir(path.join(dir, "migration"), { @@ -51,21 +43,20 @@ const migrations = await Promise.all( ) console.log(`Loaded ${migrations.length} migrations`) -const link = linker() - -await $`bun install --linker=${link} --os="*" --cpu="*" @lydell/node-pty@1.2.0-beta.10` - await Bun.build({ target: "node", entrypoints: ["./src/node.ts"], - outdir: "./dist", + outdir: "./dist/node", format: "esm", sourcemap: "linked", - external: ["jsonc-parser"], + external: ["jsonc-parser", "@lydell/node-pty"], define: { OPENCODE_MIGRATIONS: JSON.stringify(migrations), OPENCODE_CHANNEL: `'${Script.channel}'`, }, + files: { + "opencode-web-ui.gen.ts": "", + }, }) console.log("Build complete") diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 9c3d9bb5b..f760899c3 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -12,24 +12,11 @@ const dir = path.resolve(__dirname, "..") process.chdir(dir) +await import("./generate.ts") + import { Script } from "@opencode-ai/script" import pkg from "../package.json" -const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev" -// Fetch and generate models.dev snapshot -const modelsData = process.env.MODELS_DEV_API_JSON - ? await Bun.file(process.env.MODELS_DEV_API_JSON).text() - : await fetch(`${modelsUrl}/api.json`).then((x) => x.text()) -await Bun.write( - path.join(dir, "src/provider/models-snapshot.js"), - `// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`, -) -await Bun.write( - path.join(dir, "src/provider/models-snapshot.d.ts"), - `// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record\n`, -) -console.log("Generated models-snapshot.js") - // Load migrations from migration directories const migrationDirs = ( await fs.promises.readdir(path.join(dir, "migration"), { diff --git a/packages/opencode/script/generate.ts b/packages/opencode/script/generate.ts new file mode 100644 index 000000000..52d0cef8d --- /dev/null +++ b/packages/opencode/script/generate.ts @@ -0,0 +1,23 @@ +import path from "path" +import { fileURLToPath } from "url" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const dir = path.resolve(__dirname, "..") + +process.chdir(dir) + +const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev" +// Fetch and generate models.dev snapshot +const modelsData = process.env.MODELS_DEV_API_JSON + ? await Bun.file(process.env.MODELS_DEV_API_JSON).text() + : await fetch(`${modelsUrl}/api.json`).then((x) => x.text()) +await Bun.write( + path.join(dir, "src/provider/models-snapshot.js"), + `// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`, +) +await Bun.write( + path.join(dir, "src/provider/models-snapshot.d.ts"), + `// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record\n`, +) +console.log("Generated models-snapshot.js") diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md index 5882f09fe..31fcac19b 100644 --- a/packages/opencode/specs/effect-migration.md +++ b/packages/opencode/specs/effect-migration.md @@ -23,7 +23,7 @@ export namespace Foo { readonly get: (id: FooID) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/Foo") {} + export class Service extends Context.Service()("@opencode/Foo") {} export const layer = Layer.effect( Service, @@ -217,36 +217,37 @@ 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: -- [ ] `SessionTodo` — `session/todo.ts` -- [ ] `ShareNext` — `share/share-next.ts` +- [x] `SessionTodo` — `session/todo.ts` - [ ] `SyncEvent` — `sync/index.ts` - [ ] `Workspace` — `control-plane/workspace.ts` ## Tool interface → Effect -Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `init` and `execute` return `Effect` instead of `Promise`. This lets tool implementations compose natively with the Effect pipeline rather than being wrapped in `Effect.promise()` at the call site. Requires: +`Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch. Tool definitions should now stay Effect-native all the way through initialization instead of using Promise-returning init callbacks. Tools can still use lazy init callbacks when they need instance-bound state at init time, but those callbacks should return `Effect`, not `Promise`. Remaining work is: -1. Migrate each tool to return Effects -2. Update `Tool.define()` factory to work with Effects -3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing +1. Migrate each tool body to return Effects +2. Keep `Tool.define()` inputs Effect-native +3. Update remaining callers to `yield*` tool initialization instead of `await`ing ### Tool migration details -Until the tool interface itself returns `Effect`, use this transitional pattern for migrated tools: +With `Tool.Info.init()` now effectful, use this transitional pattern for migrated tools that still need Promise-based boundaries internally: - `Tool.defineEffect(...)` should `yield*` the services the tool depends on and close over them in the returned tool definition. -- Keep the bridge at the Promise boundary only. Prefer a single `Effect.runPromise(...)` in the temporary `async execute(...)` implementation, and move the inner logic into `Effect.fn(...)` helpers instead of scattering `runPromise` islands through the tool body. +- Keep the bridge at the Promise boundary only inside the tool body when required by external APIs. Do not return Promise-based init callbacks from `Tool.define()`. - If a tool starts requiring new services, wire them into `ToolRegistry.defaultLayer` so production callers resolve the same dependencies as tests. 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* Effect.promise(() => info.init())`. +- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* info.init()`. - Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production. This keeps migrated tool tests aligned with the production service graph today, and makes the eventual `Tool.Info` → `Effect` cleanup mostly mechanical later. @@ -308,3 +309,79 @@ Current raw fs users that will convert during tool migration: - [ ] `util/flock.ts` — file-based distributed lock with heartbeat → Effect.repeat + addFinalizer - [ ] `util/process.ts` — child process spawn wrapper → return Effect instead of Promise - [ ] `util/lazy.ts` — replace uses in Effect code with Effect.cached; keep for sync-only code + +## Destroying the facades + +Every service currently exports async facade functions at the bottom of its namespace — `export async function read(...) { return runPromise(...) }` — backed by a per-service `makeRuntime`. These exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them. + +### Process + +For each service, the migration is roughly: + +1. **Find callers.** `grep -n "Namespace\.(methodA|methodB|...)"` across `src/` and `test/`. Skip the service file itself. +2. **Migrate production callers.** For each effectful caller that does `Effect.tryPromise(() => Namespace.method(...))`: + - Add the service to the caller's layer R type (`Layer.Layer`) + - Yield it at the top of the layer: `const ns = yield* Namespace.Service` + - Replace `Effect.tryPromise(() => Namespace.method(...))` with `yield* ns.method(...)` (or `ns.method(...).pipe(Effect.orElseSucceed(...))` for the common fallback case) + - Add `Layer.provide(Namespace.defaultLayer)` to the caller's own `defaultLayer` chain +3. **Fix tests that used the caller's raw `.layer`.** Any test that composes `Caller.layer` (not `defaultLayer`) needs to also provide the newly-required service tag. The fastest fix is usually switching to `Caller.defaultLayer` since it now pulls in the new dependency. +4. **Migrate test callers of the facade.** Tests calling `Namespace.method(...)` directly get converted to full effectful style using `testEffect(Namespace.defaultLayer)` + `it.live` / `it.effect` + `yield* svc.method(...)`. Don't wrap the test body in `Effect.promise(async () => {...})` — do the whole thing in `Effect.gen` and use `AppFileSystem.Service` / `tmpdirScoped` / `Effect.addFinalizer` for what used to be raw `fs` / `Bun.write` / `try/finally`. +5. **Delete the facades.** Once `grep` shows zero callers, remove the `export async function` block AND the `makeRuntime(...)` line from the service namespace. Also remove the now-unused `import { makeRuntime }`. + +### Pitfalls + +- **Layer caching inside tests.** `testEffect(layer)` constructs the Storage (or whatever) service once and memoizes it. If a test then tries `inner.pipe(Effect.provide(customStorage))` to swap in a differently-configured Storage, the outer cached one wins and the inner provision is a no-op. Fix: wrap the overriding layer in `Layer.fresh(...)`, which forces a new instance to be built instead of hitting the memoMap cache. This lets a single `testEffect(...)` serve both simple and per-test-customized cases. +- **`Effect.tryPromise` → `yield*` drops the Promise layer.** The old code was `Effect.tryPromise(() => Storage.read(...))` — a `tryPromise` wrapper because the facade returned a Promise. The new code is `yield* storage.read(...)` directly — the service method already returns an Effect, so no wrapper is needed. Don't reach for `Effect.promise` or `Effect.tryPromise` during migration; if you're using them on a service method call, you're doing it wrong. +- **Raw `.layer` test callers break silently in the type checker.** When you add a new R requirement to a service's `.layer`, any test that composes it raw (not `defaultLayer`) becomes under-specified. `tsgo` will flag this — the error looks like `Type 'Storage.Service' is not assignable to type '... | Service | TestConsole'`. Usually the fix is to switch that composition to `defaultLayer`, or add `Layer.provide(NewDep.defaultLayer)` to the custom composition. +- **Tests that do async setup with `fs`, `Bun.write`, `tmpdir`.** Convert these to `AppFileSystem.Service` calls inside `Effect.gen`, and use `tmpdirScoped()` instead of `tmpdir()` so cleanup happens via the scope finalizer. For file operations on the actual filesystem (not via a service), a small helper like `const writeJson = Effect.fnUntraced(function* (file, value) { const fs = yield* AppFileSystem.Service; yield* fs.makeDirectory(path.dirname(file), { recursive: true }); yield* fs.writeFileString(file, JSON.stringify(value, null, 2)) })` keeps the migration tests clean. + +### Migration log + +- `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 diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index c5420586e..943125b79 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -202,7 +202,7 @@ Top-level API groups exposed to `tui(api, options, meta)`: - `api.kv.get`, `set`, `ready` - `api.state` - `api.theme.current`, `selected`, `has`, `set`, `install`, `mode`, `ready` -- `api.client`, `api.scopedClient(workspaceID?)`, `api.workspace.current()`, `api.workspace.set(workspaceID?)` +- `api.client` - `api.event.on(type, handler)` - `api.renderer` - `api.slots.register(plugin)` @@ -270,7 +270,6 @@ Command behavior: - `provider` - `path.{state,config,worktree,directory}` - `vcs?.branch` - - `workspace.list()` / `workspace.get(workspaceID)` - `session.count()` - `session.diff(sessionID)` - `session.todo(sessionID)` @@ -282,8 +281,6 @@ Command behavior: - `lsp()` - `mcp()` - `api.client` always reflects the current runtime client. -- `api.scopedClient(workspaceID?)` creates or reuses a client bound to a workspace. -- `api.workspace.set(...)` rebinds the active workspace; `api.client` follows that rebind. - `api.event.on(type, handler)` subscribes to the TUI event stream and returns an unsubscribe function. - `api.renderer` exposes the raw `CliRenderer`. diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index 37baf34e9..4c875caa6 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -1,4 +1,4 @@ -import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect" +import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect" import { FetchHttpClient, HttpClient, @@ -7,7 +7,6 @@ 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" @@ -181,7 +180,7 @@ export namespace Account { readonly poll: (input: Login) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/Account") {} + export class Service extends Context.Service()("@opencode/Account") {} export const layer: Layer.Layer = Layer.effect( Service, @@ -454,35 +453,4 @@ 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 { - return Option.getOrUndefined(await runPromise((service) => service.active())) - } - - export async function list(): Promise { - return runPromise((service) => service.list()) - } - - export async function activeOrg(): Promise { - return Option.getOrUndefined(await runPromise((service) => service.activeOrg())) - } - - export async function orgsByAccount(): Promise { - return runPromise((service) => service.orgsByAccount()) - } - - export async function orgs(accountID: AccountID): Promise { - return runPromise((service) => service.orgs(accountID)) - } - - export async function switchOrg(accountID: AccountID, orgID: OrgID) { - return runPromise((service) => service.use(accountID, Option.some(orgID))) - } - - export async function token(accountID: AccountID): Promise { - const t = await runPromise((service) => service.token(accountID)) - return Option.getOrUndefined(t) - } } diff --git a/packages/opencode/src/account/repo.ts b/packages/opencode/src/account/repo.ts index 4dbb9cab4..b2b084c08 100644 --- a/packages/opencode/src/account/repo.ts +++ b/packages/opencode/src/account/repo.ts @@ -1,5 +1,5 @@ import { eq } from "drizzle-orm" -import { Effect, Layer, Option, Schema, ServiceMap } from "effect" +import { Effect, Layer, Option, Schema, Context } from "effect" import { Database } from "@/storage/db" import { AccountStateTable, AccountTable } from "./account.sql" @@ -38,7 +38,7 @@ export namespace AccountRepo { } } -export class AccountRepo extends ServiceMap.Service()("@opencode/AccountRepo") { +export class AccountRepo extends Context.Service()("@opencode/AccountRepo") { static readonly layer: Layer.Layer = Layer.effect( AccountRepo, Effect.gen(function* () { diff --git a/packages/opencode/src/account/schema.ts b/packages/opencode/src/account/schema.ts index f8b3c2cf9..222296ff1 100644 --- a/packages/opencode/src/account/schema.ts +++ b/packages/opencode/src/account/schema.ts @@ -1,42 +1,22 @@ import { Schema } from "effect" import type * as HttpClientError from "effect/unstable/http/HttpClientError" -import { withStatics } from "@/util/schema" - -export const AccountID = Schema.String.pipe( - Schema.brand("AccountID"), - withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })), -) +export const AccountID = Schema.String.pipe(Schema.brand("AccountID")) export type AccountID = Schema.Schema.Type -export const OrgID = Schema.String.pipe( - Schema.brand("OrgID"), - withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })), -) +export const OrgID = Schema.String.pipe(Schema.brand("OrgID")) export type OrgID = Schema.Schema.Type -export const AccessToken = Schema.String.pipe( - Schema.brand("AccessToken"), - withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })), -) +export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken")) export type AccessToken = Schema.Schema.Type -export const RefreshToken = Schema.String.pipe( - Schema.brand("RefreshToken"), - withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })), -) +export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken")) export type RefreshToken = Schema.Schema.Type -export const DeviceCode = Schema.String.pipe( - Schema.brand("DeviceCode"), - withStatics((s) => ({ make: (code: string) => s.makeUnsafe(code) })), -) +export const DeviceCode = Schema.String.pipe(Schema.brand("DeviceCode")) export type DeviceCode = Schema.Schema.Type -export const UserCode = Schema.String.pipe( - Schema.brand("UserCode"), - withStatics((s) => ({ make: (code: string) => s.makeUnsafe(code) })), -) +export const UserCode = Schema.String.pipe(Schema.brand("UserCode")) export type UserCode = Schema.Schema.Type export class Info extends Schema.Class("Account")({ diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 0c6fe6ec9..fd9ac43e8 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -19,7 +19,7 @@ import { Global } from "@/global" import path from "path" import { Plugin } from "@/plugin" import { Skill } from "../skill" -import { Effect, ServiceMap, Layer } from "effect" +import { Effect, Context, Layer } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" @@ -67,7 +67,7 @@ export namespace Agent { type State = Omit - export class Service extends ServiceMap.Service()("@opencode/Agent") {} + export class Service extends Context.Service()("@opencode/Agent") {} export const layer = Layer.effect( Service, @@ -341,6 +341,10 @@ export namespace Agent { ) const existing = yield* InstanceState.useEffect(state, (s) => s.list()) + // TODO: clean this up so provider specific logic doesnt bleed over + const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie) + const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth" + const params = { experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry, @@ -350,12 +354,14 @@ export namespace Agent { }, temperature: 0.3, messages: [ - ...system.map( - (item): ModelMessage => ({ - role: "system", - content: item, - }), - ), + ...(isOpenaiOauth + ? [] + : system.map( + (item): ModelMessage => ({ + role: "system", + content: item, + }), + )), { role: "user", content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`, @@ -369,13 +375,12 @@ export namespace Agent { }), } satisfies Parameters[0] - // TODO: clean this up so provider specific logic doesnt bleed over - const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie) - if (model.providerID === "openai" && authInfo?.type === "oauth") { + if (isOpenaiOauth) { return yield* Effect.promise(async () => { const result = streamObject({ ...params, providerOptions: ProviderTransform.providerOptions(resolved, { + instructions: system.join("\n"), store: false, }), onError: () => {}, diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 2a9fb6c19..2e83fe287 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,5 +1,5 @@ import path from "path" -import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect" +import { Effect, Layer, Record, Result, Schema, Context } from "effect" import { makeRuntime } from "@/effect/run-service" import { zod } from "@/util/effect-zod" import { Global } from "../global" @@ -49,7 +49,7 @@ export namespace Auth { readonly remove: (key: string) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/Auth") {} + export class Service extends Context.Service()("@opencode/Auth") {} export const layer = Layer.effect( Service, diff --git a/packages/opencode/src/bus/global.ts b/packages/opencode/src/bus/global.ts index 43386dd6b..e751b59fa 100644 --- a/packages/opencode/src/bus/global.ts +++ b/packages/opencode/src/bus/global.ts @@ -4,6 +4,8 @@ export const GlobalBus = new EventEmitter<{ event: [ { directory?: string + project?: string + workspace?: string payload: any }, ] diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index fe26a6672..0638777bd 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -1,9 +1,10 @@ import z from "zod" -import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect" +import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect" +import { EffectLogger } from "@/effect/logger" import { Log } from "../util/log" -import { Instance } from "../project/instance" import { BusEvent } from "./bus-event" import { GlobalBus } from "./global" +import { WorkspaceContext } from "@/control-plane/workspace-context" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" @@ -41,7 +42,7 @@ export namespace Bus { readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void> } - export class Service extends ServiceMap.Service()("@opencode/Bus") {} + export class Service extends Context.Service()("@opencode/Bus") {} export const layer = Layer.effect( Service, @@ -91,8 +92,13 @@ export namespace Bus { yield* PubSub.publish(s.wildcard, payload) const dir = yield* InstanceState.directory + const context = yield* InstanceState.context + const workspace = yield* InstanceState.workspaceID + GlobalBus.emit("event", { directory: dir, + project: context.project.id, + workspace, payload, }) }) @@ -141,7 +147,7 @@ export namespace Bus { return () => { log.info("unsubscribing", { type }) - Effect.runFork(Scope.close(scope, Exit.void)) + Effect.runFork(Scope.close(scope, Exit.void).pipe(Effect.provide(EffectLogger.layer))) } }) } @@ -164,6 +170,8 @@ export namespace Bus { }), ) + export const defaultLayer = layer + const { runPromise, runSync } = makeRuntime(Service, layer) // runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe, diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index c09294edd..89680ebe0 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -3,6 +3,7 @@ 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" @@ -182,7 +183,7 @@ export const LoginCommand = cmd({ }), async handler(args) { UI.empty() - await Account.runPromise((_svc) => loginEffect(args.url)) + await AppRuntime.runPromise(loginEffect(args.url)) }, }) @@ -196,7 +197,7 @@ export const LogoutCommand = cmd({ }), async handler(args) { UI.empty() - await Account.runPromise((_svc) => logoutEffect(args.email)) + await AppRuntime.runPromise(logoutEffect(args.email)) }, }) @@ -205,7 +206,7 @@ export const SwitchCommand = cmd({ describe: false, async handler() { UI.empty() - await Account.runPromise((_svc) => switchEffect()) + await AppRuntime.runPromise(switchEffect()) }, }) @@ -214,7 +215,7 @@ export const OrgsCommand = cmd({ describe: false, async handler() { UI.empty() - await Account.runPromise((_svc) => orgsEffect()) + await AppRuntime.runPromise(orgsEffect()) }, }) @@ -223,7 +224,7 @@ export const OpenCommand = cmd({ describe: false, async handler() { UI.empty() - await Account.runPromise((_svc) => openEffect()) + await AppRuntime.runPromise(openEffect()) }, }) diff --git a/packages/opencode/src/cli/cmd/db.ts b/packages/opencode/src/cli/cmd/db.ts index 03e765dab..d2a2ca570 100644 --- a/packages/opencode/src/cli/cmd/db.ts +++ b/packages/opencode/src/cli/cmd/db.ts @@ -1,6 +1,7 @@ import type { Argv } from "yargs" import { spawn } from "child_process" import { Database } from "../../storage/db" +import { drizzle } from "drizzle-orm/bun-sqlite" import { Database as BunDatabase } from "bun:sqlite" import { UI } from "../ui" import { cmd } from "./cmd" @@ -74,7 +75,7 @@ const MigrateCommand = cmd({ let last = -1 if (tty) process.stderr.write("\x1b[?25l") try { - const stats = await JsonMigration.run(sqlite, { + const stats = await JsonMigration.run(drizzle({ client: sqlite }), { progress: (event) => { const percent = Math.floor((event.current / event.total) * 100) if (percent === last) return diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index 458f92547..32d10d5d7 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -1,5 +1,6 @@ import { EOL } from "os" import { basename } from "path" +import { Effect } from "effect" import { Agent } from "../../../agent/agent" import { Provider } from "../../../provider/provider" import { Session } from "../../../session" @@ -157,14 +158,16 @@ async function createToolContext(agent: Agent.Info) { agent: agent.name, abort: new AbortController().signal, messages: [], - metadata: () => {}, - async ask(req: Omit) { - for (const pattern of req.patterns) { - const rule = Permission.evaluate(req.permission, pattern, ruleset) - if (rule.action === "deny") { - throw new Permission.DeniedError({ ruleset }) + metadata: () => Effect.void, + ask(req: Omit) { + return Effect.sync(() => { + for (const pattern of req.patterns) { + const rule = Permission.evaluate(req.permission, pattern, ruleset) + if (rule.action === "deny") { + throw new Permission.DeniedError({ ruleset }) + } } - } + }) }, } } diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index e8f3e6a11..28a05512d 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -21,6 +21,7 @@ import { cmd } from "./cmd" import { ModelsDev } from "../../provider/models" import { Instance } from "@/project/instance" import { bootstrap } from "../bootstrap" +import { SessionShare } from "@/share/session" import { Session } from "../../session" import type { SessionID } from "../../session/schema" import { MessageID, PartID } from "../../session/schema" @@ -28,6 +29,7 @@ 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" @@ -257,7 +259,9 @@ export const GithubInstallCommand = cmd({ } // Get repo info - const info = (await Git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim() + const info = await AppRuntime.runPromise( + Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })), + ).then((x) => x.text().trim()) const parsed = parseGitHubRemote(info) if (!parsed) { prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) @@ -496,20 +500,21 @@ export const GithubRunCommand = cmd({ : "issue" : undefined const gitText = async (args: string[]) => { - const result = await Git.run(args, { cwd: Instance.worktree }) + const result = await AppRuntime.runPromise(Git.Service.use((git) => 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 Git.run(args, { cwd: Instance.worktree }) + const result = await AppRuntime.runPromise(Git.Service.use((git) => 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[]) => Git.run(args, { cwd: Instance.worktree }) + const gitStatus = (args: string[]) => + AppRuntime.runPromise(Git.Service.use((git) => 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>`) @@ -559,7 +564,7 @@ export const GithubRunCommand = cmd({ shareId = await (async () => { if (share === false) return if (!share && repoData.data.private) return - await Session.share(session.id) + await SessionShare.share(session.id) return session.id.slice(-8) })() console.log("opencode session", session.id) diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index a0c0101fe..1232f0742 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -10,6 +10,7 @@ import { Instance } from "../../project/instance" import { ShareNext } from "../../share/share-next" import { EOL } from "os" import { Filesystem } from "../../util/filesystem" +import { AppRuntime } from "@/effect/app-runtime" /** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */ export type ShareData = @@ -100,7 +101,7 @@ export const ImportCommand = cmd({ if (isUrl) { const slug = parseShareUrl(args.file) if (!slug) { - const baseUrl = await ShareNext.url() + const baseUrl = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.url())) process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/`) process.stdout.write(EOL) return @@ -108,7 +109,7 @@ export const ImportCommand = cmd({ const parsed = new URL(args.file) const baseUrl = parsed.origin - const req = await ShareNext.request() + const req = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.request())) const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {} const dataPath = req.api.data(slug) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index c45b9e55d..41e498102 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -688,6 +688,7 @@ export const McpDebugCommand = cmd({ clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async () => {}, diff --git a/packages/opencode/src/cli/cmd/pr.ts b/packages/opencode/src/cli/cmd/pr.ts index 58d42c6ef..f392bab4c 100644 --- a/packages/opencode/src/cli/cmd/pr.ts +++ b/packages/opencode/src/cli/cmd/pr.ts @@ -1,5 +1,6 @@ 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" @@ -67,19 +68,29 @@ export const PrCommand = cmd({ const remoteName = forkOwner // Check if remote already exists - const remotes = (await Git.run(["remote"], { cwd: Instance.worktree })).text().trim() + const remotes = await AppRuntime.runPromise( + Git.Service.use((git) => git.run(["remote"], { cwd: Instance.worktree })), + ).then((x) => x.text().trim()) if (!remotes.split("\n").includes(remoteName)) { - await Git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { - cwd: Instance.worktree, - }) + await AppRuntime.runPromise( + Git.Service.use((git) => + 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 Git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], { - cwd: Instance.worktree, - }) + await AppRuntime.runPromise( + Git.Service.use((git) => + git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], { + cwd: Instance.worktree, + }), + ), + ) } // Check for opencode session link in PR body diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 05fb3a579..04130aa95 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -7,7 +7,7 @@ import { Flag } from "../../flag/flag" import { bootstrap } from "../bootstrap" import { EOL } from "os" import { Filesystem } from "../../util/filesystem" -import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" +import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" import { Server } from "../../server/server" import { Provider } from "../../provider/provider" import { Agent } from "../../agent/agent" @@ -680,7 +680,7 @@ export const RunCommand = cmd({ await bootstrap(process.cwd(), async () => { const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init) - return Server.Default().fetch(request) + return Server.Default().app.fetch(request) }) as typeof globalThis.fetch const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) await execute(sdk) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index e50d46429..408c91b5c 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -14,7 +14,6 @@ import { batch, Show, on, - onCleanup, } from "solid-js" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { Flag } from "@/flag/flag" @@ -23,6 +22,8 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" import { ErrorComponent } from "@tui/component/error-component" import { PluginRouteMissing } from "@tui/component/plugin-route-missing" +import { ProjectProvider, useProject } from "@tui/context/project" +import { useEvent } from "@tui/context/event" import { SDKProvider, useSDK } from "@tui/context/sdk" import { StartupLoading } from "@tui/component/startup-loading" import { SyncProvider, useSync } from "@tui/context/sync" @@ -36,7 +37,6 @@ import { DialogPair } from "@tui/component/dialog-pair" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" -import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list" import { DialogConsoleOrg } from "@tui/component/dialog-console-org" import { KeybindProvider, useKeybind } from "@tui/context/keybind" import { ThemeProvider, useTheme } from "@tui/context/theme" @@ -55,7 +55,6 @@ import { KVProvider, useKV } from "./context/kv" import { Provider } from "@/provider/provider" import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" -import { writeHeapSnapshot } from "v8" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { TuiConfigProvider, useTuiConfig } from "./context/tui-config" import { TuiConfig } from "@/config/tui" @@ -217,27 +216,29 @@ export function tui(input: { headers={input.headers} events={input.events} > - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + @@ -261,6 +262,7 @@ function App(props: { onSnapshot?: () => Promise }) { const kv = useKV() const command = useCommandDialog() const keybind = useKeybind() + const event = useEvent() const sdk = useSDK() const toast = useToast() const themeState = useTheme() @@ -284,6 +286,7 @@ function App(props: { onSnapshot?: () => Promise }) { route, routes, bump: () => setRouteRev((x) => x + 1), + event, sdk, sync, theme: themeState, @@ -462,22 +465,6 @@ function App(props: { onSnapshot?: () => Promise }) { dialog.replace(() => ) }, }, - ...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES - ? [ - { - title: "Manage workspaces", - value: "workspace.list", - category: "Workspace", - suggested: true, - slash: { - name: "workspaces", - }, - onSelect: () => { - dialog.replace(() => ) - }, - }, - ] - : []), { title: "New session", suggested: route.data.type === "session", @@ -492,12 +479,9 @@ function App(props: { onSnapshot?: () => Promise }) { const current = promptRef.current // Don't require focus - if there's any text, preserve it const currentPrompt = current?.current?.input ? current.current : undefined - const workspaceID = - route.data.type === "session" ? sync.session.get(route.data.sessionID)?.workspaceID : undefined route.navigate({ type: "home", initialPrompt: currentPrompt, - workspaceID, }) dialog.clear() }, @@ -818,11 +802,11 @@ function App(props: { onSnapshot?: () => Promise }) { }, ]) - sdk.event.on(TuiEvent.CommandExecute.type, (evt) => { + event.on(TuiEvent.CommandExecute.type, (evt) => { command.trigger(evt.properties.command) }) - sdk.event.on(TuiEvent.ToastShow.type, (evt) => { + event.on(TuiEvent.ToastShow.type, (evt) => { toast.show({ title: evt.properties.title, message: evt.properties.message, @@ -831,14 +815,14 @@ function App(props: { onSnapshot?: () => Promise }) { }) }) - sdk.event.on(TuiEvent.SessionSelect.type, (evt) => { + event.on(TuiEvent.SessionSelect.type, (evt) => { route.navigate({ type: "session", sessionID: evt.properties.sessionID, }) }) - sdk.event.on("session.deleted", (evt) => { + event.on("session.deleted", (evt) => { if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) { route.navigate({ type: "home" }) toast.show({ @@ -848,7 +832,7 @@ function App(props: { onSnapshot?: () => Promise }) { } }) - sdk.event.on("session.error", (evt) => { + event.on("session.error", (evt) => { const error = evt.properties.error if (error && typeof error === "object" && error.name === "MessageAbortedError") return const message = errorMessage(error) @@ -860,7 +844,7 @@ function App(props: { onSnapshot?: () => Promise }) { }) }) - sdk.event.on("installation.update-available", async (evt) => { + event.on("installation.update-available", async (evt) => { const version = evt.properties.version const skipped = kv.get("skipped_version") diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx new file mode 100644 index 000000000..2d200ca3b --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx @@ -0,0 +1,99 @@ +import { RGBA, TextAttributes } from "@opentui/core" +import { useKeyboard } from "@opentui/solid" +import open from "open" +import { createSignal } from "solid-js" +import { selectedForeground, useTheme } from "@tui/context/theme" +import { useDialog, type DialogContext } from "@tui/ui/dialog" +import { Link } from "@tui/ui/link" + +const GO_URL = "https://opencode.ai/go" + +export type DialogGoUpsellProps = { + onClose?: (dontShowAgain?: boolean) => void +} + +function subscribe(props: DialogGoUpsellProps, dialog: ReturnType) { + open(GO_URL).catch(() => {}) + props.onClose?.() + dialog.clear() +} + +function dismiss(props: DialogGoUpsellProps, dialog: ReturnType) { + props.onClose?.(true) + dialog.clear() +} + +export function DialogGoUpsell(props: DialogGoUpsellProps) { + const dialog = useDialog() + const { theme } = useTheme() + const fg = selectedForeground(theme) + const [selected, setSelected] = createSignal(0) + + useKeyboard((evt) => { + if (evt.name === "left" || evt.name === "right" || evt.name === "tab") { + setSelected((s) => (s === 0 ? 1 : 0)) + return + } + if (evt.name !== "return") return + if (selected() === 0) subscribe(props, dialog) + else dismiss(props, dialog) + }) + + return ( + + + + Free limit reached + + dialog.clear()}> + esc + + + + + Subscribe to OpenCode Go to keep going with reliable access to the best open-source models, starting at + $5/month. + + + + + + + setSelected(0)} + onMouseUp={() => subscribe(props, dialog)} + > + + subscribe + + + setSelected(1)} + onMouseUp={() => dismiss(props, dialog)} + > + + don't show again + + + + + ) +} + +DialogGoUpsell.show = (dialog: DialogContext) => { + return new Promise((resolve) => { + dialog.replace( + () => resolve(dontShow ?? false)} />, + () => resolve(false), + ) + }) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 775969bfc..9ecb21e82 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -2,25 +2,31 @@ import { useDialog } from "@tui/ui/dialog" import { DialogSelect } from "@tui/ui/dialog-select" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" -import { createMemo, createSignal, createResource, onMount, Show } from "solid-js" +import { createMemo, createResource, createSignal, onMount } from "solid-js" import { Locale } from "@/util/locale" +import { useProject } from "@tui/context/project" import { useKeybind } from "../context/keybind" import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" +import { Flag } from "@/flag/flag" import { DialogSessionRename } from "./dialog-session-rename" -import { useKV } from "../context/kv" +import { Keybind } from "@/util/keybind" import { createDebouncedSignal } from "../util/signal" +import { useToast } from "../ui/toast" +import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create" import { Spinner } from "./spinner" +type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error" + export function DialogSessionList() { const dialog = useDialog() const route = useRoute() const sync = useSync() + const project = useProject() const keybind = useKeybind() const { theme } = useTheme() const sdk = useSDK() - const kv = useKV() - + const toast = useToast() const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) @@ -31,15 +37,68 @@ export function DialogSessionList() { }) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) - const sessions = createMemo(() => searchResults() ?? sync.data.session) + function createWorkspace() { + dialog.replace(() => ( + + openWorkspaceSession({ + dialog, + route, + sdk, + sync, + toast, + workspaceID, + }) + } + /> + )) + } + const options = createMemo(() => { const today = new Date().toDateString() return sessions() .filter((x) => x.parentID === undefined) .toSorted((a, b) => b.time.updated - a.time.updated) .map((x) => { + const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined + + let workspaceStatus: WorkspaceStatus | null = null + if (x.workspaceID) { + workspaceStatus = project.workspace.status(x.workspaceID) || "error" + } + + let footer = "" + if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { + if (x.workspaceID) { + let desc = "unknown" + if (workspace) { + desc = `${workspace.type}: ${workspace.name}` + } + + footer = ( + <> + {desc}{" "} + + ■ + + + ) + } + } else { + footer = Locale.time(x.time.updated) + } + const date = new Date(x.time.updated) let category = date.toDateString() if (category === today) { @@ -53,7 +112,7 @@ export function DialogSessionList() { bg: isDeleting ? theme.error : undefined, value: x.id, category, - footer: Locale.time(x.time.updated), + footer, gutter: isWorking ? : undefined, } }) @@ -102,6 +161,15 @@ export function DialogSessionList() { dialog.replace(() => ) }, }, + { + keybind: Keybind.parse("ctrl+w")[0], + title: "new workspace", + side: "right", + disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, + onTrigger: () => { + createWorkspace() + }, + }, ]} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx new file mode 100644 index 000000000..40cc1013e --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -0,0 +1,121 @@ +import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect } from "@tui/ui/dialog-select" +import { useRoute } from "@tui/context/route" +import { useSync } from "@tui/context/sync" +import { useProject } from "@tui/context/project" +import { createMemo, createSignal, onMount } from "solid-js" +import { setTimeout as sleep } from "node:timers/promises" +import { useSDK } from "../context/sdk" +import { useToast } from "../ui/toast" + +function scoped(sdk: ReturnType, sync: ReturnType, workspaceID: string) { + return createOpencodeClient({ + baseUrl: sdk.url, + fetch: sdk.fetch, + directory: sync.path.directory || sdk.directory, + experimental_workspaceID: workspaceID, + }) +} + +export async function openWorkspaceSession(input: { + dialog: ReturnType + route: ReturnType + sdk: ReturnType + sync: ReturnType + toast: ReturnType + workspaceID: string +}) { + const client = scoped(input.sdk, input.sync, input.workspaceID) + while (true) { + const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined) + if (!result) { + input.toast.show({ + message: "Failed to create workspace session", + variant: "error", + }) + return + } + if (result.response.status >= 500 && result.response.status < 600) { + await sleep(1000) + continue + } + if (!result.data) { + input.toast.show({ + message: "Failed to create workspace session", + variant: "error", + }) + return + } + input.route.navigate({ + type: "session", + sessionID: result.data.id, + }) + input.dialog.clear() + return + } +} + +export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise | void }) { + const dialog = useDialog() + const sync = useSync() + const project = useProject() + const sdk = useSDK() + const toast = useToast() + const [creating, setCreating] = createSignal() + + onMount(() => { + dialog.setSize("medium") + }) + + const options = createMemo(() => { + const type = creating() + if (type) { + return [ + { + title: `Creating ${type} workspace...`, + value: "creating" as const, + description: "This can take a while for remote environments", + }, + ] + } + return [ + { + title: "Worktree", + value: "worktree" as const, + description: "Create a local git worktree", + }, + ] + }) + + const create = async (type: string) => { + if (creating()) return + setCreating(type) + + const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => undefined) + const workspace = result?.data + if (!workspace) { + setCreating(undefined) + toast.show({ + message: "Failed to create workspace", + variant: "error", + }) + return + } + await project.workspace.sync() + await props.onSelect(workspace.id) + setCreating(undefined) + } + + return ( + { + if (option.value === "creating") return + void create(option.value) + }} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx deleted file mode 100644 index 84127b576..000000000 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx +++ /dev/null @@ -1,320 +0,0 @@ -import { useDialog } from "@tui/ui/dialog" -import { DialogSelect } from "@tui/ui/dialog-select" -import { useRoute } from "@tui/context/route" -import { useSync } from "@tui/context/sync" -import { createEffect, createMemo, createSignal, onMount } from "solid-js" -import { createOpencodeClient, type Session } from "@opencode-ai/sdk/v2" -import { useSDK } from "../context/sdk" -import { useToast } from "../ui/toast" -import { useKeybind } from "../context/keybind" -import { DialogSessionList } from "./workspace/dialog-session-list" -import { setTimeout as sleep } from "node:timers/promises" - -function scoped(sdk: ReturnType, sync: ReturnType, workspaceID?: string) { - return createOpencodeClient({ - baseUrl: sdk.url, - fetch: sdk.fetch, - directory: sync.data.path.directory || sdk.directory, - experimental_workspaceID: workspaceID, - }) -} - -async function openWorkspace(input: { - dialog: ReturnType - route: ReturnType - sdk: ReturnType - sync: ReturnType - toast: ReturnType - workspaceID: string - forceCreate?: boolean -}) { - const cacheSession = (session: Session) => { - input.sync.set( - "session", - [...input.sync.data.session.filter((item) => item.id !== session.id), session].toSorted((a, b) => - a.id.localeCompare(b.id), - ), - ) - } - - const client = scoped(input.sdk, input.sync, input.workspaceID) - const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 }) - const session = listed?.data?.[0] - if (session?.id) { - cacheSession(session) - input.route.navigate({ - type: "session", - sessionID: session.id, - }) - input.dialog.clear() - return - } - let created: Session | undefined - while (!created) { - const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined) - if (!result) { - input.toast.show({ - message: "Failed to open workspace", - variant: "error", - }) - return - } - if (result.response.status >= 500 && result.response.status < 600) { - await sleep(1000) - continue - } - if (!result.data) { - input.toast.show({ - message: "Failed to open workspace", - variant: "error", - }) - return - } - created = result.data - } - cacheSession(created) - input.route.navigate({ - type: "session", - sessionID: created.id, - }) - input.dialog.clear() -} - -function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise }) { - const dialog = useDialog() - const sync = useSync() - const sdk = useSDK() - const toast = useToast() - const [creating, setCreating] = createSignal() - - onMount(() => { - dialog.setSize("medium") - }) - - const options = createMemo(() => { - const type = creating() - if (type) { - return [ - { - title: `Creating ${type} workspace...`, - value: "creating" as const, - description: "This can take a while for remote environments", - }, - ] - } - return [ - { - title: "Worktree", - value: "worktree" as const, - description: "Create a local git worktree", - }, - ] - }) - - const createWorkspace = async (type: string) => { - if (creating()) return - setCreating(type) - - const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => { - console.log(err) - return undefined - }) - console.log(JSON.stringify(result, null, 2)) - const workspace = result?.data - if (!workspace) { - setCreating(undefined) - toast.show({ - message: "Failed to create workspace", - variant: "error", - }) - return - } - await sync.workspace.sync() - await props.onSelect(workspace.id) - setCreating(undefined) - } - - return ( - { - if (option.value === "creating") return - void createWorkspace(option.value) - }} - /> - ) -} - -export function DialogWorkspaceList() { - const dialog = useDialog() - const route = useRoute() - const sync = useSync() - const sdk = useSDK() - const toast = useToast() - const keybind = useKeybind() - const [toDelete, setToDelete] = createSignal() - const [counts, setCounts] = createSignal>({}) - - const open = (workspaceID: string, forceCreate?: boolean) => - openWorkspace({ - dialog, - route, - sdk, - sync, - toast, - workspaceID, - forceCreate, - }) - - async function selectWorkspace(workspaceID: string) { - if (workspaceID === "__local__") { - if (localCount() > 0) { - dialog.replace(() => ) - return - } - route.navigate({ - type: "home", - }) - dialog.clear() - return - } - const count = counts()[workspaceID] - if (count && count > 0) { - dialog.replace(() => ) - return - } - - if (count === 0) { - await open(workspaceID) - return - } - const client = scoped(sdk, sync, workspaceID) - const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined) - if (listed?.data?.length) { - dialog.replace(() => ) - return - } - await open(workspaceID) - } - - const currentWorkspaceID = createMemo(() => { - if (route.data.type === "session") { - return sync.session.get(route.data.sessionID)?.workspaceID ?? "__local__" - } - return "__local__" - }) - - const localCount = createMemo( - () => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length, - ) - - let run = 0 - createEffect(() => { - const workspaces = sync.data.workspaceList - const next = ++run - if (!workspaces.length) { - setCounts({}) - return - } - setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined]))) - void Promise.all( - workspaces.map(async (workspace) => { - const client = scoped(sdk, sync, workspace.id) - const result = await client.session.list({ roots: true }).catch(() => undefined) - return [workspace.id, result ? (result.data?.length ?? 0) : null] as const - }), - ).then((entries) => { - if (run !== next) return - setCounts(Object.fromEntries(entries)) - }) - }) - - const options = createMemo(() => [ - { - title: "Local", - value: "__local__", - category: "Workspace", - description: "Use the local machine", - footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`, - }, - ...sync.data.workspaceList.map((workspace) => { - const count = counts()[workspace.id] - return { - title: - toDelete() === workspace.id - ? `Delete ${workspace.id}? Press ${keybind.print("session_delete")} again` - : workspace.id, - value: workspace.id, - category: workspace.type, - description: workspace.branch ? `Branch ${workspace.branch}` : undefined, - footer: - count === undefined - ? "Loading sessions..." - : count === null - ? "Sessions unavailable" - : `${count} session${count === 1 ? "" : "s"}`, - } - }), - { - title: "+ New workspace", - value: "__create__", - category: "Actions", - description: "Create a new workspace", - }, - ]) - - onMount(() => { - dialog.setSize("large") - void sync.workspace.sync() - }) - - return ( - { - setToDelete(undefined) - }} - onSelect={(option) => { - setToDelete(undefined) - if (option.value === "__create__") { - dialog.replace(() => open(workspaceID, true)} />) - return - } - void selectWorkspace(option.value) - }} - keybind={[ - { - keybind: keybind.all.session_delete?.[0], - title: "delete", - onTrigger: async (option) => { - if (option.value === "__create__" || option.value === "__local__") return - if (toDelete() !== option.value) { - setToDelete(option.value) - return - } - const result = await sdk.client.experimental.workspace.remove({ id: option.value }).catch(() => undefined) - setToDelete(undefined) - if (result?.error) { - toast.show({ - message: "Failed to delete workspace", - variant: "error", - }) - return - } - if (currentWorkspaceID() === option.value) { - route.navigate({ - type: "home", - }) - } - await sync.workspace.sync() - }, - }, - ]} - /> - ) -} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 1c5ede4d7..2118fe98e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -250,7 +250,7 @@ export function Autocomplete(props: { const width = props.anchor().width - 4 options.push( ...sortedFiles.map((item): AutocompleteOption => { - const baseDir = (sync.data.path.directory || process.cwd()).replace(/\/+$/, "") + const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "") const fullPath = `${baseDir}/${item}` const urlObj = pathToFileURL(fullPath) let filename = item diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 747c61fd0..5a3e1d451 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -10,6 +10,7 @@ import { EmptyBorder, SplitBorder } from "@tui/component/border" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" +import { useEvent } from "@tui/context/event" import { MessageID, PartID } from "@/session/schema" import { createStore, produce } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" @@ -115,8 +116,9 @@ export function Prompt(props: PromptProps) { const agentStyleId = syntax().getStyleId("extmark.agent")! const pasteStyleId = syntax().getStyleId("extmark.paste")! let promptPartTypeId = 0 + const event = useEvent() - sdk.event.on(TuiEvent.PromptAppend.type, (evt) => { + event.on(TuiEvent.PromptAppend.type, (evt) => { if (!input || input.isDestroyed) return input.insertText(evt.properties.text) setTimeout(() => { @@ -587,6 +589,13 @@ export function Prompt(props: PromptProps) { ]) async function submit() { + // IME: double-defer may fire before onContentChange flushes the last + // composed character (e.g. Korean hangul) to the store, so read + // plainText directly and sync before any downstream reads. + if (input && !input.isDestroyed && input.plainText !== store.prompt.input) { + setStore("prompt", "input", input.plainText) + syncExtmarksWithPromptParts() + } if (props.disabled) return if (autocomplete?.visible) return if (!store.prompt.input) return @@ -992,7 +1001,11 @@ export function Prompt(props: PromptProps) { input.cursorOffset = input.plainText.length } }} - onSubmit={submit} + onSubmit={() => { + // IME: double-defer so the last composed character (e.g. Korean + // hangul) is flushed to plainText before we read it for submission. + setTimeout(() => setTimeout(() => submit(), 0), 0) + }} onPaste={async (event: PasteEvent) => { if (props.disabled) { event.preventDefault() diff --git a/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx deleted file mode 100644 index 326f094a5..000000000 --- a/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { useDialog } from "@tui/ui/dialog" -import { DialogSelect } from "@tui/ui/dialog-select" -import { useRoute } from "@tui/context/route" -import { useSync } from "@tui/context/sync" -import { createMemo, createSignal, createResource, onMount, Show } from "solid-js" -import { Locale } from "@/util/locale" -import { useKeybind } from "../../context/keybind" -import { useTheme } from "../../context/theme" -import { useSDK } from "../../context/sdk" -import { DialogSessionRename } from "../dialog-session-rename" -import { useKV } from "../../context/kv" -import { createDebouncedSignal } from "../../util/signal" -import { Spinner } from "../spinner" -import { useToast } from "../../ui/toast" - -export function DialogSessionList(props: { workspaceID?: string; localOnly?: boolean } = {}) { - const dialog = useDialog() - const route = useRoute() - const sync = useSync() - const keybind = useKeybind() - const { theme } = useTheme() - const sdk = useSDK() - const kv = useKV() - const toast = useToast() - const [toDelete, setToDelete] = createSignal() - const [search, setSearch] = createDebouncedSignal("", 150) - - const [listed, listedActions] = createResource( - () => props.workspaceID, - async (workspaceID) => { - if (!workspaceID) return undefined - const result = await sdk.client.session.list({ roots: true }) - return result.data ?? [] - }, - ) - - const [searchResults] = createResource(search, async (query) => { - if (!query || props.localOnly) return undefined - const result = await sdk.client.session.list({ - search: query, - limit: 30, - ...(props.workspaceID ? { roots: true } : {}), - }) - return result.data ?? [] - }) - - const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) - - const sessions = createMemo(() => { - if (searchResults()) return searchResults()! - if (props.workspaceID) return listed() ?? [] - if (props.localOnly) return sync.data.session.filter((session) => !session.workspaceID) - return sync.data.session - }) - - const options = createMemo(() => { - const today = new Date().toDateString() - return sessions() - .filter((x) => { - if (x.parentID !== undefined) return false - if (props.workspaceID && listed()) return true - if (props.workspaceID) return x.workspaceID === props.workspaceID - if (props.localOnly) return !x.workspaceID - return true - }) - .toSorted((a, b) => b.time.updated - a.time.updated) - .map((x) => { - const date = new Date(x.time.updated) - let category = date.toDateString() - if (category === today) { - category = "Today" - } - const isDeleting = toDelete() === x.id - const status = sync.data.session_status?.[x.id] - const isWorking = status?.type === "busy" - return { - title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title, - bg: isDeleting ? theme.error : undefined, - value: x.id, - category, - footer: Locale.time(x.time.updated), - gutter: isWorking ? : undefined, - } - }) - }) - - onMount(() => { - dialog.setSize("large") - }) - - return ( - { - setToDelete(undefined) - }} - onSelect={(option) => { - route.navigate({ - type: "session", - sessionID: option.value, - }) - dialog.clear() - }} - keybind={[ - { - keybind: keybind.all.session_delete?.[0], - title: "delete", - onTrigger: async (option) => { - if (toDelete() === option.value) { - const deleted = await sdk.client.session - .delete({ - sessionID: option.value, - }) - .then(() => true) - .catch(() => false) - setToDelete(undefined) - if (!deleted) { - toast.show({ - message: "Failed to delete session", - variant: "error", - }) - return - } - if (props.workspaceID) { - listedActions.mutate((sessions) => sessions?.filter((session) => session.id !== option.value)) - return - } - sync.set( - "session", - sync.data.session.filter((session) => session.id !== option.value), - ) - return - } - setToDelete(option.value) - }, - }, - { - keybind: keybind.all.session_rename?.[0], - title: "rename", - onTrigger: async (option) => { - dialog.replace(() => ) - }, - }, - ]} - /> - ) -} diff --git a/packages/opencode/src/cli/cmd/tui/context/directory.ts b/packages/opencode/src/cli/cmd/tui/context/directory.ts index 17e5c180a..81f217398 100644 --- a/packages/opencode/src/cli/cmd/tui/context/directory.ts +++ b/packages/opencode/src/cli/cmd/tui/context/directory.ts @@ -1,11 +1,13 @@ import { createMemo } from "solid-js" +import { useProject } from "./project" import { useSync } from "./sync" import { Global } from "@/global" export function useDirectory() { + const project = useProject() const sync = useSync() return createMemo(() => { - const directory = sync.data.path.directory || process.cwd() + const directory = project.instance.path().directory || process.cwd() const result = directory.replace(Global.Path.home, "~") if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch return result diff --git a/packages/opencode/src/cli/cmd/tui/context/event.ts b/packages/opencode/src/cli/cmd/tui/context/event.ts new file mode 100644 index 000000000..da073f6e9 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/event.ts @@ -0,0 +1,41 @@ +import type { Event } from "@opencode-ai/sdk/v2" +import { useProject } from "./project" +import { useSDK } from "./sdk" + +export function useEvent() { + const project = useProject() + const sdk = useSDK() + + function subscribe(handler: (event: Event) => void) { + return sdk.event.on("event", (event) => { + // Special hack for truly global events + if (event.directory === "global") { + handler(event.payload) + } + + if (project.workspace.current()) { + if (event.workspace === project.workspace.current()) { + handler(event.payload) + } + + return + } + + if (event.directory === project.instance.directory()) { + handler(event.payload) + } + }) + } + + function on(type: T, handler: (event: Extract) => void) { + return subscribe((event) => { + if (event.type !== type) return + handler(event as Extract) + }) + } + + return { + subscribe, + on, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/context/project.tsx b/packages/opencode/src/cli/cmd/tui/context/project.tsx new file mode 100644 index 000000000..26e5c075d --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/project.tsx @@ -0,0 +1,106 @@ +import { batch } from "solid-js" +import type { Path, Workspace } from "@opencode-ai/sdk/v2" +import { createStore, reconcile } from "solid-js/store" +import { createSimpleContext } from "./helper" +import { useSDK } from "./sdk" + +type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error" + +export const { use: useProject, provider: ProjectProvider } = createSimpleContext({ + name: "Project", + init: () => { + const sdk = useSDK() + const [store, setStore] = createStore({ + project: { + id: undefined as string | undefined, + }, + instance: { + path: { + home: "", + state: "", + config: "", + worktree: "", + directory: sdk.directory ?? "", + } satisfies Path, + }, + workspace: { + current: undefined as string | undefined, + list: [] as Workspace[], + status: {} as Record, + }, + }) + + async function sync() { + const workspace = store.workspace.current + const [path, project] = await Promise.all([ + sdk.client.path.get({ workspace }), + sdk.client.project.current({ workspace }), + ]) + + batch(() => { + setStore("instance", "path", reconcile(path.data!)) + setStore("project", "id", project.data?.id) + }) + } + + async function syncWorkspace() { + const listed = await sdk.client.experimental.workspace.list().catch(() => undefined) + if (!listed?.data) return + const status = await sdk.client.experimental.workspace.status().catch(() => undefined) + const next = Object.fromEntries((status?.data ?? []).map((item) => [item.workspaceID, item.status])) + + batch(() => { + setStore("workspace", "list", reconcile(listed.data)) + setStore("workspace", "status", reconcile(next)) + if (!listed.data.some((item) => item.id === store.workspace.current)) { + setStore("workspace", "current", undefined) + } + }) + } + + sdk.event.on("event", (event) => { + if (event.payload.type === "workspace.status") { + setStore("workspace", "status", event.payload.properties.workspaceID, event.payload.properties.status) + } + }) + + return { + data: store, + project() { + return store.project.id + }, + instance: { + path() { + return store.instance.path + }, + directory() { + return store.instance.path.directory + }, + }, + workspace: { + current() { + return store.workspace.current + }, + set(next?: string | null) { + const workspace = next ?? undefined + if (store.workspace.current === workspace) return + setStore("workspace", "current", workspace) + }, + list() { + return store.workspace.list + }, + get(workspaceID: string) { + return store.workspace.list.find((item) => item.id === workspaceID) + }, + status(workspaceID: string) { + return store.workspace.status[workspaceID] + }, + statuses() { + return store.workspace.status + }, + sync: syncWorkspace, + }, + sync, + } + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/context/route.tsx b/packages/opencode/src/cli/cmd/tui/context/route.tsx index 939c2d5dc..e9f463a13 100644 --- a/packages/opencode/src/cli/cmd/tui/context/route.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/route.tsx @@ -5,7 +5,6 @@ import type { PromptInfo } from "../component/prompt/history" export type HomeRoute = { type: "home" initialPrompt?: PromptInfo - workspaceID?: string } export type SessionRoute = { diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 348c3ca1d..ad35aa45c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -1,10 +1,11 @@ -import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2" +import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import type { GlobalEvent, Event } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { batch, onCleanup, onMount } from "solid-js" export type EventSource = { - subscribe: (directory: string | undefined, handler: (event: Event) => void) => Promise<() => void> + subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void> } export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ @@ -32,10 +33,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ let sdk = createSDK() const emitter = createGlobalEmitter<{ - [key in Event["type"]]: Extract + event: GlobalEvent }>() - let queue: Event[] = [] + let queue: GlobalEvent[] = [] let timer: Timer | undefined let last = 0 @@ -48,12 +49,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ // Batch all event emissions so all store updates result in a single render batch(() => { for (const event of events) { - emitter.emit(event.type, event) + emitter.emit("event", event) } }) } - const handleEvent = (event: Event) => { + const handleEvent = (event: GlobalEvent) => { queue.push(event) const elapsed = Date.now() - last @@ -74,7 +75,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ ;(async () => { while (true) { if (abort.signal.aborted || ctrl.signal.aborted) break - const events = await sdk.event.subscribe({}, { signal: ctrl.signal }) + const events = await sdk.global.event({ signal: ctrl.signal }) for await (const event of events.stream) { if (ctrl.signal.aborted) break @@ -89,7 +90,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ onMount(async () => { if (props.events) { - const unsub = await props.events.subscribe(props.directory, handleEvent) + const unsub = await props.events.subscribe(handleEvent) onCleanup(unsub) } else { startSSE() diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 11336d500..498db99a1 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -19,16 +19,16 @@ import type { VcsInfo, } from "@opencode-ai/sdk/v2" import { createStore, produce, reconcile } from "solid-js/store" +import { useProject } from "@tui/context/project" +import { useEvent } from "@tui/context/event" import { useSDK } from "@tui/context/sdk" import { Binary } from "@opencode-ai/util/binary" import { createSimpleContext } from "./helper" import type { Snapshot } from "@/snapshot" import { useExit } from "./exit" import { useArgs } from "./args" -import { batch, onMount } from "solid-js" +import { batch, createEffect, on } from "solid-js" import { Log } from "@/util/log" -import type { Path } from "@opencode-ai/sdk" -import type { Workspace } from "@opencode-ai/sdk/v2" import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state" export const { use: useSync, provider: SyncProvider } = createSimpleContext({ @@ -75,8 +75,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } formatter: FormatterStatus[] vcs: VcsInfo | undefined - path: Path - workspaceList: Workspace[] }>({ provider_next: { all: [], @@ -104,20 +102,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ mcp_resource: {}, formatter: [], vcs: undefined, - path: { state: "", config: "", worktree: "", directory: "" }, - workspaceList: [], }) + const event = useEvent() + const project = useProject() const sdk = useSDK() - async function syncWorkspaces() { - const result = await sdk.client.experimental.workspace.list().catch(() => undefined) - if (!result?.data) return - setStore("workspaceList", reconcile(result.data)) - } - - sdk.event.listen((e) => { - const event = e.details + event.subscribe((event) => { switch (event.type) { case "server.instance.disposed": bootstrap() @@ -344,7 +335,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } case "lsp.updated": { - sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)) + const workspace = project.workspace.current() + sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data!)) break } @@ -360,25 +352,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ async function bootstrap() { console.log("bootstrapping") + const workspace = project.workspace.current() const start = Date.now() - 30 * 24 * 60 * 60 * 1000 const sessionListPromise = sdk.client.session .list({ start: start }) .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) // blocking - include session.list when continuing a session - const providersPromise = sdk.client.config.providers({}, { throwOnError: true }) - const providerListPromise = sdk.client.provider.list({}, { throwOnError: true }) + const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true }) + const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true }) const consoleStatePromise = sdk.client.experimental.console - .get({}, { throwOnError: true }) + .get({ workspace }, { throwOnError: true }) .then((x) => ConsoleState.parse(x.data)) .catch(() => emptyConsoleState) - const agentsPromise = sdk.client.app.agents({}, { throwOnError: true }) - const configPromise = sdk.client.config.get({}, { throwOnError: true }) + const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true }) + const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true }) + const projectPromise = project.sync() const blockingRequests: Promise[] = [ providersPromise, providerListPromise, agentsPromise, configPromise, + projectPromise, ...(args.continue ? [sessionListPromise] : []), ] @@ -423,18 +418,19 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ Promise.all([ ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]), consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))), - sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))), - sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))), - sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))), - sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))), - sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))), - sdk.client.session.status().then((x) => { + sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))), + sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data!))), + sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data!))), + sdk.client.experimental.resource + .list({ workspace }) + .then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))), + sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data!))), + sdk.client.session.status({ workspace }).then((x) => { setStore("session_status", reconcile(x.data!)) }), - sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))), - sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))), - sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))), - syncWorkspaces(), + sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))), + sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))), + project.workspace.sync(), ]).then(() => { setStore("status", "complete") }) @@ -449,11 +445,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) } - onMount(() => { - bootstrap() - }) - const fullSyncedSessions = new Set() + createEffect( + on( + () => project.workspace.current(), + () => { + fullSyncedSessions.clear() + void bootstrap() + }, + ), + ) + const result = { data: store, set: setStore, @@ -463,6 +465,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ get ready() { return store.status !== "loading" }, + get path() { + return project.instance.path() + }, session: { get(sessionID: string) { const match = Binary.search(store.session, sessionID, (s) => s.id) @@ -481,11 +486,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }, async sync(sessionID: string) { if (fullSyncedSessions.has(sessionID)) return + const workspace = project.workspace.current() const [session, messages, todo, diff] = await Promise.all([ - sdk.client.session.get({ sessionID }, { throwOnError: true }), - sdk.client.session.messages({ sessionID, limit: 100 }), - sdk.client.session.todo({ sessionID }), - sdk.client.session.diff({ sessionID }), + sdk.client.session.get({ sessionID, workspace }, { throwOnError: true }), + sdk.client.session.messages({ sessionID, limit: 100, workspace }), + sdk.client.session.todo({ sessionID, workspace }), + sdk.client.session.diff({ sessionID, workspace }), ]) setStore( produce((draft) => { @@ -503,12 +509,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ fullSyncedSessions.add(sessionID) }, }, - workspace: { - get(workspaceID: string) { - return store.workspaceList.find((workspace) => workspace.id === workspaceID) - }, - sync: syncWorkspaces, - }, bootstrap, } return result diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 3609f6cc1..42bf78adb 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -1,6 +1,7 @@ import type { ParsedKey } from "@opentui/core" import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui" import type { useCommandDialog } from "@tui/component/dialog-command" +import type { useEvent } from "@tui/context/event" import type { useKeybind } from "@tui/context/keybind" import type { useRoute } from "@tui/context/route" import type { useSDK } from "@tui/context/sdk" @@ -36,6 +37,7 @@ type Input = { route: ReturnType routes: RouteMap bump: () => void + event: ReturnType sdk: ReturnType sync: ReturnType theme: ReturnType @@ -136,7 +138,7 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { return sync.data.provider }, get path() { - return sync.data.path + return sync.path }, get vcs() { if (!sync.data.vcs) return @@ -144,14 +146,6 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { branch: sync.data.vcs.branch, } }, - workspace: { - list() { - return sync.data.workspaceList - }, - get(workspaceID) { - return sync.workspace.get(workspaceID) - }, - }, session: { count() { return sync.data.session.length @@ -342,7 +336,7 @@ export function createTuiApi(input: Input): TuiPluginApi { get client() { return input.sdk.client }, - event: input.sdk.event, + event: input.event, renderer: input.renderer, slots: { register() { diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 79b5c4d7a..1cce7fb39 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -1,6 +1,7 @@ import { Prompt, type PromptRef } from "@tui/component/prompt" import { createEffect, createSignal } from "solid-js" import { Logo } from "../component/logo" +import { useProject } from "../context/project" import { useSync } from "../context/sync" import { Toast } from "../ui/toast" import { useArgs } from "../context/args" @@ -18,6 +19,7 @@ const placeholder = { export function Home() { const sync = useSync() + const project = useProject() const route = useRouteData("home") const promptRef = usePromptRef() const [ref, setRef] = createSignal() @@ -63,11 +65,16 @@ export function Home() { - + } + workspaceID={project.workspace.current()} + right={} placeholders={placeholder} /> diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 396d75630..c6bc231fc 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -15,7 +15,9 @@ import { import { Dynamic } from "solid-js/web" import path from "path" import { useRoute, useRouteData } from "@tui/context/route" +import { useProject } from "@tui/context/project" import { useSync } from "@tui/context/sync" +import { useEvent } from "@tui/context/event" import { SplitBorder } from "@tui/component/border" import { Spinner } from "@tui/component/spinner" import { selectedForeground, useTheme } from "@tui/context/theme" @@ -83,9 +85,15 @@ import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" import { getScrollAcceleration } from "../../util/scroll" import { TuiPluginRuntime } from "../../plugin" +import { DialogGoUpsell } from "../../component/dialog-go-upsell" +import { SessionRetry } from "@/session/retry" addDefaultParsers(parsers.parsers) +const GO_UPSELL_LAST_SEEN_AT = "go_upsell_last_seen_at" +const GO_UPSELL_DONT_SHOW = "go_upsell_dont_show" +const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs + const context = createContext<{ width: number sessionID: string @@ -110,6 +118,8 @@ export function Session() { const route = useRouteData("session") const { navigate } = useRoute() const sync = useSync() + const event = useEvent() + const project = useProject() const tuiConfig = useTuiConfig() const kv = useKV() const { theme } = useTheme() @@ -149,7 +159,7 @@ export function Session() { const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide") const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true) const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true) - const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", true) + const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false) const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word") const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true) const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false) @@ -166,10 +176,16 @@ export function Session() { const providers = createMemo(() => Model.index(sync.data.provider)) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) + const toast = useToast() + const sdk = useSDK() createEffect(async () => { - await sync.session - .sync(route.sessionID) + await sdk.client.session + .get({ sessionID: route.sessionID }, { throwOnError: true }) + .then((x) => { + project.workspace.set(x.data?.workspaceID) + }) + .then(() => sync.session.sync(route.sessionID)) .then(() => { if (scroll) scroll.scrollBy(100_000) }) @@ -183,13 +199,10 @@ export function Session() { }) }) - const toast = useToast() - const sdk = useSDK() - // Handle initial prompt from fork let seeded = false let lastSwitch: string | undefined = undefined - sdk.event.on("message.part.updated", (evt) => { + event.on("message.part.updated", (evt) => { const part = evt.properties.part if (part.type !== "tool") return if (part.sessionID !== route.sessionID) return @@ -218,6 +231,23 @@ export function Session() { const dialog = useDialog() const renderer = useRenderer() + event.on("session.status", (evt) => { + if (evt.properties.sessionID !== route.sessionID) return + if (evt.properties.status.type !== "retry") return + if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return + if (dialog.stack.length > 0) return + + const seen = kv.get(GO_UPSELL_LAST_SEEN_AT) + if (typeof seen === "number" && Date.now() - seen < GO_UPSELL_WINDOW) return + + if (kv.get(GO_UPSELL_DONT_SHOW)) return + + DialogGoUpsell.show(dialog).then((dontShowAgain) => { + if (dontShowAgain) kv.set(GO_UPSELL_DONT_SHOW, true) + kv.set(GO_UPSELL_LAST_SEEN_AT, Date.now()) + }) + }) + // Allow exit when in child session (prompt is hidden) const exit = useExit() @@ -1768,7 +1798,7 @@ function Bash(props: ToolProps) { const workdir = props.input.workdir if (!workdir || workdir === ".") return undefined - const base = sync.data.path.directory + const base = sync.path.directory if (!base) return undefined const absolute = path.resolve(base, workdir) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index df5c41677..972e67d10 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -10,7 +10,7 @@ import { errorMessage } from "@/util/error" import { withTimeout } from "@/util/timeout" import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" import { Filesystem } from "@/util/filesystem" -import type { Event } from "@opencode-ai/sdk/v2" +import type { GlobalEvent } from "@opencode-ai/sdk/v2" import type { EventSource } from "./context/sdk" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { TuiConfig } from "@/config/tui" @@ -43,18 +43,10 @@ function createWorkerFetch(client: RpcClient): typeof fetch { function createEventSource(client: RpcClient): EventSource { return { - subscribe: async (directory, handler) => { - const id = await client.call("subscribe", { directory }) - const unsub = client.on<{ id: string; event: Event }>("event", (e) => { - if (e.id === id) { - handler(e.event) - } + subscribe: async (handler) => { + return client.on("global.event", (e) => { + handler(e) }) - - return () => { - unsub() - client.call("unsubscribe", { id }) - } }, } } @@ -145,12 +137,18 @@ export const TuiThreadCommand = cmd({ ), }) worker.onerror = (e) => { - Log.Default.error(e) + Log.Default.error("thread error", { + message: e.message, + filename: e.filename, + lineno: e.lineno, + colno: e.colno, + error: e.error, + }) } const client = Rpc.client(worker) const error = (e: unknown) => { - Log.Default.error(e) + Log.Default.error("process error", { error: errorMessage(e) }) } const reload = () => { client.call("reload", undefined).catch((err) => { diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 46821ccce..109b5f2f1 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -26,6 +26,7 @@ export interface DialogSelectProps { keybind?: { keybind?: Keybind.Info title: string + side?: "left" | "right" disabled?: boolean onTrigger: (option: DialogSelectOption) => void }[] @@ -42,6 +43,7 @@ export interface DialogSelectOption { disabled?: boolean bg?: RGBA gutter?: JSX.Element + margin?: JSX.Element onSelect?: (ctx: DialogContext) => void } @@ -234,6 +236,8 @@ export function DialogSelect(props: DialogSelectProps) { props.ref?.(ref) const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? []) + const left = createMemo(() => keybinds().filter((item) => item.side !== "right")) + const right = createMemo(() => keybinds().filter((item) => item.side === "right")) return ( @@ -312,6 +316,7 @@ export function DialogSelect(props: DialogSelectProps) { { setStore("input", "mouse") }} @@ -335,6 +340,11 @@ export function DialogSelect(props: DialogSelectProps) { paddingRight={3} gap={1} > + + + {option.margin} + + diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 0b9ec82dc..a71b95ce4 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -6,13 +6,10 @@ import { InstanceBootstrap } from "@/project/bootstrap" import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" import { Config } from "@/config/config" -import { Bus } from "@/bus" import { GlobalBus } from "@/bus/global" -import type { Event } from "@opencode-ai/sdk/v2" +import type { GlobalEvent } from "@opencode-ai/sdk/v2" import { Flag } from "@/flag/flag" -import { setTimeout as sleep } from "node:timers/promises" import { writeHeapSnapshot } from "node:v8" -import { WorkspaceID } from "@/control-plane/schema" import { Heap } from "@/cli/heap" await Log.init({ @@ -45,87 +42,6 @@ GlobalBus.on("event", (event) => { let server: Awaited> | undefined -const eventStreams = new Map() - -function startEventStream(directory: string) { - const id = crypto.randomUUID() - - const abort = new AbortController() - const signal = abort.signal - - eventStreams.set(id, abort) - - async function run() { - while (!signal.aborted) { - const shouldReconnect = await Instance.provide({ - directory, - init: InstanceBootstrap, - fn: () => - new Promise((resolve) => { - Rpc.emit("event", { - type: "server.connected", - properties: {}, - } satisfies Event) - - let settled = false - const settle = (value: boolean) => { - if (settled) return - settled = true - signal.removeEventListener("abort", onAbort) - unsub() - resolve(value) - } - - const unsub = Bus.subscribeAll((event) => { - Rpc.emit("event", { - id, - event: event as Event, - }) - if (event.type === Bus.InstanceDisposed.type) { - settle(true) - } - }) - - const onAbort = () => { - settle(false) - } - - signal.addEventListener("abort", onAbort, { once: true }) - }), - }).catch((error) => { - Log.Default.error("event stream subscribe error", { - error: error instanceof Error ? error.message : error, - }) - return false - }) - - if (!shouldReconnect || signal.aborted) { - break - } - - if (!signal.aborted) { - await sleep(250) - } - } - } - - run().catch((error) => { - Log.Default.error("event stream error", { - error: error instanceof Error ? error.message : error, - }) - }) - - return id -} - -function stopEventStream(id: string) { - const abortController = eventStreams.get(id) - if (!abortController) return - - abortController.abort() - eventStreams.delete(id) -} - export const rpc = { async fetch(input: { url: string; method: string; headers: Record; body?: string }) { const headers = { ...input.headers } @@ -138,7 +54,7 @@ export const rpc = { headers, body: input.body, }) - const response = await Server.Default().fetch(request) + const response = await Server.Default().app.fetch(request) const body = await response.text() return { status: response.status, @@ -167,19 +83,9 @@ export const rpc = { async reload() { await Config.invalidate(true) }, - async subscribe(input: { directory: string | undefined }) { - return startEventStream(input.directory || process.cwd()) - }, - async unsubscribe(input: { id: string }) { - stopEventStream(input.id) - }, async shutdown() { Log.Default.info("worker shutting down") - for (const id of [...eventStreams.keys()]) { - stopEventStream(id) - } - await Instance.disposeAll() if (server) await server.stop(true) }, diff --git a/packages/opencode/src/cli/cmd/uninstall.ts b/packages/opencode/src/cli/cmd/uninstall.ts index de41f32a0..31830f085 100644 --- a/packages/opencode/src/cli/cmd/uninstall.ts +++ b/packages/opencode/src/cli/cmd/uninstall.ts @@ -1,6 +1,7 @@ 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" @@ -57,7 +58,7 @@ export const UninstallCommand = { UI.empty() prompts.intro("Uninstall OpenCode") - const method = await Installation.method() + const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method())) prompts.log.info(`Installation method: ${method}`) const targets = await collectRemovalTargets(args, method) diff --git a/packages/opencode/src/cli/cmd/upgrade.ts b/packages/opencode/src/cli/cmd/upgrade.ts index 018205663..3ffa0f228 100644 --- a/packages/opencode/src/cli/cmd/upgrade.ts +++ b/packages/opencode/src/cli/cmd/upgrade.ts @@ -1,6 +1,7 @@ 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 = { @@ -24,7 +25,7 @@ export const UpgradeCommand = { UI.println(UI.logo(" ")) UI.empty() prompts.intro("Upgrade") - const detectedMethod = await Installation.method() + const detectedMethod = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.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`) @@ -42,7 +43,9 @@ export const UpgradeCommand = { } } prompts.log.info("Using method: " + method) - const target = args.target ? args.target.replace(/^v/, "") : await Installation.latest() + const target = args.target + ? args.target.replace(/^v/, "") + : await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest())) if (Installation.VERSION === target) { prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`) @@ -53,7 +56,9 @@ export const UpgradeCommand = { prompts.log.info(`From ${Installation.VERSION} → ${target}`) const spinner = prompts.spinner() spinner.start("Upgrading...") - const err = await Installation.upgrade(method, target).catch((err) => err) + const err = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, target))).catch( + (err) => err, + ) if (err) { spinner.stop("Upgrade failed", 1) if (err instanceof Installation.UpgradeFailedError) { diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts index 7b7199d4e..e902dcb92 100644 --- a/packages/opencode/src/cli/upgrade.ts +++ b/packages/opencode/src/cli/upgrade.ts @@ -1,12 +1,13 @@ 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 Installation.method() - const latest = await Installation.latest(method).catch(() => {}) + const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method())) + const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {}) if (!latest) return if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) { @@ -25,7 +26,7 @@ export async function upgrade() { } if (method === "unknown") return - await Installation.upgrade(method, latest) + await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, latest))) .then(() => Bus.publish(Installation.Event.Updated, { version: latest })) .catch(() => {}) } diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 088d7c565..42f53301b 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -1,8 +1,9 @@ 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, ServiceMap } from "effect" +import { Effect, Layer, Context } from "effect" +import { EffectLogger } from "@/effect/logger" import z from "zod" import { Config } from "../config/config" import { MCP } from "../mcp" @@ -70,7 +71,7 @@ export namespace Command { readonly list: () => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/Command") {} + export class Service extends Context.Service()("@opencode/Command") {} export const layer = Layer.effect( Service, @@ -79,7 +80,7 @@ export namespace Command { const mcp = yield* MCP.Service const skill = yield* Skill.Service - const init = Effect.fn("Command.state")(function* (ctx) { + const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) { const cfg = yield* config.get() const commands: Record = {} @@ -140,6 +141,7 @@ export namespace Command { .map((message) => (message.content.type === "text" ? message.content.text : "")) .join("\n") || "", ), + Effect.provide(EffectLogger.layer), ), ) }, @@ -186,10 +188,4 @@ 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()) - } } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index efae2ca55..ecce8fb8f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -4,7 +4,6 @@ import { pathToFileURL } from "url" import os from "os" import { Process } from "../util/process" import z from "zod" -import { ModelsDev } from "../provider/models" import { mergeDeep, pipe, unique } from "remeda" import { Global } from "../global" import fsNode from "fs/promises" @@ -37,10 +36,11 @@ import type { ConsoleState } from "./console-state" import { AppFileSystem } from "@/filesystem" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" -import { Duration, Effect, Layer, Option, ServiceMap } from "effect" +import { Duration, Effect, Layer, Option, Context } from "effect" import { Flock } from "@/util/flock" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" import { Npm } from "@/npm" +import { InstanceRef } from "@/effect/instance-ref" export namespace Config { const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) @@ -399,6 +399,10 @@ export namespace Config { .describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."), clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"), scope: z.string().optional().describe("OAuth scopes to request during authorization"), + redirectUri: z + .string() + .optional() + .describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."), }) .strict() .meta({ @@ -786,28 +790,81 @@ export namespace Config { }) export type Layout = z.infer - export const Provider = ModelsDev.Provider.partial() - .extend({ - whitelist: z.array(z.string()).optional(), - blacklist: z.array(z.string()).optional(), - models: z + export const Model = z + .object({ + id: z.string(), + name: z.string(), + family: z.string().optional(), + release_date: z.string(), + attachment: z.boolean(), + reasoning: z.boolean(), + temperature: z.boolean(), + tool_call: z.boolean(), + interleaved: z + .union([ + z.literal(true), + z + .object({ + field: z.enum(["reasoning_content", "reasoning_details"]), + }) + .strict(), + ]) + .optional(), + cost: z + .object({ + input: z.number(), + output: z.number(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + context_over_200k: z + .object({ + input: z.number(), + output: z.number(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + }) + .optional(), + }) + .optional(), + limit: z.object({ + context: z.number(), + input: z.number().optional(), + output: z.number(), + }), + modalities: z + .object({ + input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), + output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), + }) + .optional(), + experimental: z.boolean().optional(), + status: z.enum(["alpha", "beta", "deprecated"]).optional(), + provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(), + options: z.record(z.string(), z.any()), + headers: z.record(z.string(), z.string()).optional(), + variants: z .record( z.string(), - ModelsDev.Model.partial().extend({ - variants: z - .record( - z.string(), - z - .object({ - disabled: z.boolean().optional().describe("Disable this variant for the model"), - }) - .catchall(z.any()), - ) - .optional() - .describe("Variant-specific configuration"), - }), + z + .object({ + disabled: z.boolean().optional().describe("Disable this variant for the model"), + }) + .catchall(z.any()), ) - .optional(), + .optional() + .describe("Variant-specific configuration"), + }) + .partial() + + export const Provider = z + .object({ + api: z.string().optional(), + name: z.string(), + env: z.array(z.string()), + id: z.string(), + npm: z.string().optional(), + whitelist: z.array(z.string()).optional(), + blacklist: z.array(z.string()).optional(), options: z .object({ apiKey: z.string().optional(), @@ -840,11 +897,14 @@ export namespace Config { }) .catchall(z.any()) .optional(), + models: z.record(z.string(), Model).optional(), }) + .partial() .strict() .meta({ ref: "ProviderConfig", }) + export type Provider = z.infer export const Info = z @@ -1066,7 +1126,7 @@ export namespace Config { readonly waitForDependencies: () => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/Config") {} + export class Service extends Context.Service()("@opencode/Config") {} function globalConfigFile() { const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) => @@ -1267,27 +1327,31 @@ export namespace Config { const consoleManagedProviders = new Set() let activeOrgName: string | undefined - const scope = (source: string): PluginScope => { + const scope = Effect.fnUntraced(function* (source: string) { if (source.startsWith("http://") || source.startsWith("https://")) return "global" if (source === "OPENCODE_CONFIG_CONTENT") return "local" - if (Instance.containsPath(source)) return "local" + if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local" return "global" - } + }) - const track = (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) => { + const track = Effect.fnUntraced(function* ( + source: string, + list: PluginSpec[] | undefined, + kind?: PluginScope, + ) { if (!list?.length) return - const hit = kind ?? scope(source) + const hit = kind ?? (yield* scope(source)) const plugins = deduplicatePluginOrigins([ ...(result.plugin_origins ?? []), ...list.map((spec) => ({ spec, source, scope: hit })), ]) result.plugin = plugins.map((item) => item.spec) result.plugin_origins = plugins - } + }) const merge = (source: string, next: Info, kind?: PluginScope) => { result = mergeConfigConcatArrays(result, next) - track(source, next.plugin, kind) + return track(source, next.plugin, kind) } for (const [key, value] of Object.entries(auth)) { @@ -1307,16 +1371,16 @@ export namespace Config { dir: path.dirname(source), source, }) - merge(source, next, "global") + yield* merge(source, next, "global") log.debug("loaded remote config from well-known", { url }) } } const global = yield* getGlobal() - merge(Global.Path.config, global, "global") + yield* merge(Global.Path.config, global, "global") if (Flag.OPENCODE_CONFIG) { - merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG)) + yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG)) log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) } @@ -1324,7 +1388,7 @@ export namespace Config { for (const file of yield* Effect.promise(() => ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree), )) { - merge(file, yield* loadFile(file), "local") + yield* merge(file, yield* loadFile(file), "local") } } @@ -1345,7 +1409,7 @@ export namespace Config { for (const file of ["opencode.json", "opencode.jsonc"]) { const source = path.join(dir, file) log.debug(`loading config from ${source}`) - merge(source, yield* loadFile(source)) + yield* merge(source, yield* loadFile(source)) result.agent ??= {} result.mode ??= {} result.plugin ??= [] @@ -1364,7 +1428,7 @@ export namespace Config { result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir))) result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir))) const list = yield* Effect.promise(() => loadPlugin(dir)) - track(dir, list) + yield* track(dir, list) } if (process.env.OPENCODE_CONFIG_CONTENT) { @@ -1373,7 +1437,7 @@ export namespace Config { dir: ctx.directory, source, }) - merge(source, next, "local") + yield* merge(source, next, "local") log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") } @@ -1402,7 +1466,7 @@ export namespace Config { for (const providerID of Object.keys(next.provider ?? {})) { consoleManagedProviders.add(providerID) } - merge(source, next, "global") + yield* merge(source, next, "global") } }).pipe( Effect.catch((err) => { @@ -1417,7 +1481,7 @@ export namespace Config { if (existsSync(managedDir)) { for (const file of ["opencode.json", "opencode.jsonc"]) { const source = path.join(managedDir, file) - merge(source, yield* loadFile(source), "global") + yield* merge(source, yield* loadFile(source), "global") } } diff --git a/packages/opencode/src/control-plane/schema.ts b/packages/opencode/src/control-plane/schema.ts index 7618f46ad..7262a380b 100644 --- a/packages/opencode/src/control-plane/schema.ts +++ b/packages/opencode/src/control-plane/schema.ts @@ -10,8 +10,7 @@ export type WorkspaceID = typeof workspaceIdSchema.Type export const WorkspaceID = workspaceIdSchema.pipe( withStatics((schema: typeof workspaceIdSchema) => ({ - make: (id: string) => schema.makeUnsafe(id), - ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("workspace", id)), + ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)), zod: Identifier.schema("workspace").pipe(z.custom()), })), ) diff --git a/packages/opencode/src/control-plane/workspace-context.ts b/packages/opencode/src/control-plane/workspace-context.ts new file mode 100644 index 000000000..173ec6178 --- /dev/null +++ b/packages/opencode/src/control-plane/workspace-context.ts @@ -0,0 +1,22 @@ +import { LocalContext } from "../util/local-context" +import type { WorkspaceID } from "../control-plane/schema" + +export interface WorkspaceContext { + workspaceID: string +} + +const context = LocalContext.create("instance") + +export const WorkspaceContext = { + async provide(input: { workspaceID: WorkspaceID; fn: () => R }): Promise { + return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn()) + }, + + get workspaceID() { + try { + return context.use().workspaceID + } catch (err) { + return undefined + } + }, +} diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index bb0fd6002..bbf79620c 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -5,7 +5,9 @@ import { Database, eq } from "@/storage/db" import { Project } from "@/project/project" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" +import { SyncEvent } from "@/sync" import { Log } from "@/util/log" +import { Filesystem } from "@/util/filesystem" import { ProjectID } from "@/project/schema" import { WorkspaceTable } from "./workspace.sql" import { getAdaptor } from "./adaptors" @@ -14,6 +16,18 @@ import { WorkspaceID } from "./schema" import { parseSSE } from "./sse" export namespace Workspace { + export const Info = WorkspaceInfo.meta({ + ref: "Workspace", + }) + export type Info = z.infer + + export const ConnectionStatus = z.object({ + workspaceID: WorkspaceID.zod, + status: z.enum(["connected", "connecting", "disconnected", "error"]), + error: z.string().optional(), + }) + export type ConnectionStatus = z.infer + export const Event = { Ready: BusEvent.define( "workspace.ready", @@ -27,13 +41,9 @@ export namespace Workspace { message: z.string(), }), ), + Status: BusEvent.define("workspace.status", ConnectionStatus), } - export const Info = WorkspaceInfo.meta({ - ref: "Workspace", - }) - export type Info = z.infer - function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { return { id: row.id, @@ -85,6 +95,9 @@ export namespace Workspace { }) await adaptor.create(config) + + startSync(info) + return info }) @@ -92,18 +105,24 @@ export namespace Workspace { const rows = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(), ) - return rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id)) + const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id)) + for (const space of spaces) startSync(space) + return spaces } export const get = fn(WorkspaceID.zod, async (id) => { const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) if (!row) return - return fromRow(row) + const space = fromRow(row) + startSync(space) + return space }) export const remove = fn(WorkspaceID.zod, async (id) => { const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) if (row) { + stopSync(id) + const info = fromRow(row) const adaptor = await getAdaptor(row.type) adaptor.remove(info) @@ -111,58 +130,100 @@ export namespace Workspace { return info } }) + + const connections = new Map() + const aborts = new Map() + + function setStatus(id: WorkspaceID, status: ConnectionStatus["status"], error?: string) { + const prev = connections.get(id) + if (prev?.status === status && prev?.error === error) return + const next = { workspaceID: id, status, error } + connections.set(id, next) + GlobalBus.emit("event", { + directory: "global", + workspace: id, + payload: { + type: Event.Status.type, + properties: next, + }, + }) + } + + export function status(): ConnectionStatus[] { + return [...connections.values()] + } + const log = Log.create({ service: "workspace-sync" }) - async function workspaceEventLoop(space: Info, stop: AbortSignal) { - while (!stop.aborted) { + async function workspaceEventLoop(space: Info, signal: AbortSignal) { + log.info("starting sync: " + space.id) + + while (!signal.aborted) { + log.info("connecting to sync: " + space.id) + + setStatus(space.id, "connecting") const adaptor = await getAdaptor(space.type) - const target = await Promise.resolve(adaptor.target(space)) + const target = await adaptor.target(space) - if (target.type === "local") { - return - } + if (target.type === "local") return - const baseURL = String(target.url).replace(/\/?$/, "/") - - const res = await fetch(new URL(baseURL + "/event"), { - method: "GET", - signal: stop, + const res = await fetch(target.url + "/sync/event", { method: "GET", signal }).catch((err: unknown) => { + setStatus(space.id, "error", String(err)) + return undefined }) + if (!res || !res.ok || !res.body) { + log.info("failed to connect to sync: " + res?.status) - if (!res.ok || !res.body) { + setStatus(space.id, "error", res ? `HTTP ${res.status}` : "no response") await sleep(1000) continue } + setStatus(space.id, "connected") + await parseSSE(res.body, signal, (evt) => { + const event = evt as SyncEvent.SerializedEvent - await parseSSE(res.body, stop, (event) => { - GlobalBus.emit("event", { - directory: space.id, - payload: event, - }) + try { + if (!event.type.startsWith("server.")) { + SyncEvent.replay(event) + } + } catch (err) { + log.warn("failed to replay sync event", { + workspaceID: space.id, + error: err, + }) + } }) - - // Wait 250ms and retry if SSE connection fails + setStatus(space.id, "disconnected") + log.info("disconnected to sync: " + space.id) await sleep(250) } } - export function startSyncing(project: Project.Info) { - const stop = new AbortController() - const spaces = list(project).filter((space) => space.type !== "worktree") + function startSync(space: Info) { + if (space.type === "worktree") { + void Filesystem.exists(space.directory!).then((exists) => { + setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist") + }) + return + } - spaces.forEach((space) => { - void workspaceEventLoop(space, stop.signal).catch((error) => { - log.warn("workspace sync listener failed", { - workspaceID: space.id, - error, - }) + if (aborts.has(space.id)) return + const abort = new AbortController() + aborts.set(space.id, abort) + setStatus(space.id, "disconnected") + + void workspaceEventLoop(space, abort.signal).catch((error) => { + setStatus(space.id, "error", String(error)) + log.warn("workspace sync listener failed", { + workspaceID: space.id, + error, }) }) + } - return { - async stop() { - stop.abort() - }, - } + function stopSync(id: WorkspaceID) { + aborts.get(id)?.abort() + aborts.delete(id) + connections.delete(id) } } diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts new file mode 100644 index 000000000..674ca1a2a --- /dev/null +++ b/packages/opencode/src/effect/app-runtime.ts @@ -0,0 +1,100 @@ +import { Layer, ManagedRuntime } from "effect" +import { memoMap } from "./run-service" +import { Observability } from "./oltp" + +import { AppFileSystem } from "@/filesystem" +import { Bus } from "@/bus" +import { Auth } from "@/auth" +import { Account } from "@/account" +import { Config } from "@/config/config" +import { Git } from "@/git" +import { Ripgrep } from "@/file/ripgrep" +import { FileTime } from "@/file/time" +import { File } from "@/file" +import { FileWatcher } from "@/file/watcher" +import { Storage } from "@/storage/storage" +import { Snapshot } from "@/snapshot" +import { Plugin } from "@/plugin" +import { Provider } from "@/provider/provider" +import { ProviderAuth } from "@/provider/auth" +import { Agent } from "@/agent/agent" +import { Skill } from "@/skill" +import { Discovery } from "@/skill/discovery" +import { Question } from "@/question" +import { Permission } from "@/permission" +import { Todo } from "@/session/todo" +import { Session } from "@/session" +import { SessionStatus } from "@/session/status" +import { SessionRunState } from "@/session/run-state" +import { SessionProcessor } from "@/session/processor" +import { SessionCompaction } from "@/session/compaction" +import { SessionRevert } from "@/session/revert" +import { SessionSummary } from "@/session/summary" +import { SessionPrompt } from "@/session/prompt" +import { Instruction } from "@/session/instruction" +import { LLM } from "@/session/llm" +import { LSP } from "@/lsp" +import { MCP } from "@/mcp" +import { McpAuth } from "@/mcp/auth" +import { Command } from "@/command" +import { Truncate } from "@/tool/truncate" +import { ToolRegistry } from "@/tool/registry" +import { Format } from "@/format" +import { Project } from "@/project/project" +import { Vcs } from "@/project/vcs" +import { Worktree } from "@/worktree" +import { Pty } from "@/pty" +import { Installation } from "@/installation" +import { ShareNext } from "@/share/share-next" +import { SessionShare } from "@/share/session" + +export const AppLayer = Layer.mergeAll( + Observability.layer, + AppFileSystem.defaultLayer, + Bus.defaultLayer, + Auth.defaultLayer, + Account.defaultLayer, + Config.defaultLayer, + Git.defaultLayer, + Ripgrep.defaultLayer, + FileTime.defaultLayer, + File.defaultLayer, + FileWatcher.defaultLayer, + Storage.defaultLayer, + Snapshot.defaultLayer, + Plugin.defaultLayer, + Provider.defaultLayer, + ProviderAuth.defaultLayer, + Agent.defaultLayer, + Skill.defaultLayer, + Discovery.defaultLayer, + Question.defaultLayer, + Permission.defaultLayer, + Todo.defaultLayer, + Session.defaultLayer, + SessionStatus.defaultLayer, + SessionRunState.defaultLayer, + SessionProcessor.defaultLayer, + SessionCompaction.defaultLayer, + SessionRevert.defaultLayer, + SessionSummary.defaultLayer, + SessionPrompt.defaultLayer, + Instruction.defaultLayer, + LLM.defaultLayer, + LSP.defaultLayer, + MCP.defaultLayer, + McpAuth.defaultLayer, + Command.defaultLayer, + Truncate.defaultLayer, + ToolRegistry.defaultLayer, + Format.defaultLayer, + Project.defaultLayer, + Vcs.defaultLayer, + Worktree.defaultLayer, + Pty.defaultLayer, + Installation.defaultLayer, + ShareNext.defaultLayer, + SessionShare.defaultLayer, +) + +export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap }) diff --git a/packages/opencode/src/effect/bootstrap-runtime.ts b/packages/opencode/src/effect/bootstrap-runtime.ts new file mode 100644 index 000000000..78df313e8 --- /dev/null +++ b/packages/opencode/src/effect/bootstrap-runtime.ts @@ -0,0 +1,10 @@ +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 }) diff --git a/packages/opencode/src/effect/cross-spawn-spawner.ts b/packages/opencode/src/effect/cross-spawn-spawner.ts index 92e5b3ba2..5e25263a0 100644 --- a/packages/opencode/src/effect/cross-spawn-spawner.ts +++ b/packages/opencode/src/effect/cross-spawn-spawner.ts @@ -402,6 +402,7 @@ export const make = Effect.gen(function* () { const fd = yield* setupFds(command, proc, extra) const out = setupOutput(command, proc, sout, serr) + let ref = true return makeHandle({ pid: ProcessId(proc.pid!), stdin: yield* setupStdin(command, proc, sin), @@ -432,6 +433,18 @@ export const make = Effect.gen(function* () { orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid), }) }, + unref: Effect.sync(() => { + if (ref) { + proc.unref() + ref = false + } + return Effect.sync(() => { + if (!ref) { + proc.ref() + ref = true + } + }) + }), }) } case "PipedCommand": { @@ -499,4 +512,3 @@ const rt = lazy(async () => { type RT = Awaited> export const runPromiseExit: RT["runPromiseExit"] = async (...args) => (await rt()).runPromiseExit(...(args as [any])) -export const runPromise: RT["runPromise"] = async (...args) => (await rt()).runPromise(...(args as [any])) diff --git a/packages/opencode/src/effect/instance-ref.ts b/packages/opencode/src/effect/instance-ref.ts index d3939b264..301316c77 100644 --- a/packages/opencode/src/effect/instance-ref.ts +++ b/packages/opencode/src/effect/instance-ref.ts @@ -1,6 +1,10 @@ -import { ServiceMap } from "effect" +import { Context } from "effect" import type { InstanceContext } from "@/project/instance" -export const InstanceRef = ServiceMap.Reference("~opencode/InstanceRef", { +export const InstanceRef = Context.Reference("~opencode/InstanceRef", { + defaultValue: () => undefined, +}) + +export const WorkspaceRef = Context.Reference("~opencode/WorkspaceRef", { defaultValue: () => undefined, }) diff --git a/packages/opencode/src/effect/instance-state.ts b/packages/opencode/src/effect/instance-state.ts index cc5901fb5..b3392d156 100644 --- a/packages/opencode/src/effect/instance-state.ts +++ b/packages/opencode/src/effect/instance-state.ts @@ -1,8 +1,10 @@ -import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect" +import { Effect, Fiber, ScopedCache, Scope, Context } from "effect" +import { EffectLogger } from "@/effect/logger" import { Instance, type InstanceContext } from "@/project/instance" -import { Context } from "@/util/context" -import { InstanceRef } from "./instance-ref" +import { LocalContext } from "@/util/local-context" +import { InstanceRef, WorkspaceRef } from "./instance-ref" import { registerDisposer } from "./instance-registry" +import { WorkspaceContext } from "@/control-plane/workspace-context" const TypeId = "~opencode/InstanceState" @@ -16,10 +18,10 @@ export namespace InstanceState { try { return Instance.bind(fn) } catch (err) { - if (!(err instanceof Context.NotFound)) throw err + if (!(err instanceof LocalContext.NotFound)) throw err } const fiber = Fiber.getCurrent() - const ctx = fiber ? ServiceMap.getReferenceUnsafe(fiber.services, InstanceRef) : undefined + const ctx = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined if (!ctx) return fn return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F } @@ -28,6 +30,10 @@ export namespace InstanceState { return (yield* InstanceRef) ?? Instance.current }) + export const workspaceID = Effect.gen(function* () { + return (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID + }) + export const directory = Effect.map(context, (ctx) => ctx.directory) export const make = ( @@ -42,7 +48,9 @@ export namespace InstanceState { }), }) - const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory))) + const off = registerDisposer((directory) => + Effect.runPromise(ScopedCache.invalidate(cache, directory).pipe(Effect.provide(EffectLogger.layer))), + ) yield* Effect.addFinalizer(() => Effect.sync(off)) return { @@ -73,10 +81,4 @@ export namespace InstanceState { Effect.gen(function* () { return yield* ScopedCache.invalidate(self.cache, yield* directory) }) - - /** - * Effect finalizers run on the fiber scheduler after the original async - * boundary, so ALS reads like Instance.directory can be gone by then. - */ - export const withALS = (fn: () => T) => Effect.map(context, (ctx) => Instance.restore(ctx, fn)) } diff --git a/packages/opencode/src/effect/logger.ts b/packages/opencode/src/effect/logger.ts new file mode 100644 index 000000000..7a7f0a541 --- /dev/null +++ b/packages/opencode/src/effect/logger.ts @@ -0,0 +1,67 @@ +import { Cause, Effect, Logger, References } from "effect" +import { Log } from "@/util/log" + +export namespace EffectLogger { + type Fields = Record + + export interface Handle { + readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect + readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect + readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect + readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect + readonly with: (extra: Fields) => Handle + } + + const clean = (input?: Fields): Fields => + Object.fromEntries(Object.entries(input ?? {}).filter((entry) => entry[1] !== undefined && entry[1] !== null)) + + const text = (input: unknown): string => { + if (Array.isArray(input)) return input.map((item) => String(item)).join(" ") + return input === undefined ? "" : String(input) + } + + const call = (run: (msg?: unknown) => Effect.Effect, base: Fields, msg?: unknown, extra?: Fields) => { + const ann = clean({ ...base, ...extra }) + const fx = run(msg) + return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx + } + + export const logger = Logger.make((opts) => { + const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations)) + const now = opts.date.getTime() + for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) { + extra[`logSpan.${key}`] = `${now - start}ms` + } + if (opts.cause.reasons.length > 0) { + extra.cause = Cause.pretty(opts.cause) + } + + const svc = typeof extra.service === "string" ? extra.service : undefined + if (svc) delete extra.service + const log = svc ? Log.create({ service: svc }) : Log.Default + const msg = text(opts.message) + + switch (opts.logLevel) { + case "Trace": + case "Debug": + return log.debug(msg, extra) + case "Warn": + return log.warn(msg, extra) + case "Error": + case "Fatal": + return log.error(msg, extra) + default: + return log.info(msg, extra) + } + }) + + export const layer = Logger.layer([logger], { mergeWithExisting: false }) + + export const create = (base: Fields = {}): Handle => ({ + debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra), + info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra), + warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra), + error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra), + with: (extra) => create({ ...base, ...extra }), + }) +} diff --git a/packages/opencode/src/effect/oltp.ts b/packages/opencode/src/effect/oltp.ts index 1fa697fb6..33b67151a 100644 --- a/packages/opencode/src/effect/oltp.ts +++ b/packages/opencode/src/effect/oltp.ts @@ -1,34 +1,41 @@ -import { Layer } from "effect" +import { Duration, Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { Otlp } from "effect/unstable/observability" +import { EffectLogger } from "@/effect/logger" import { Flag } from "@/flag/flag" import { CHANNEL, VERSION } from "@/installation/meta" export namespace Observability { - export const enabled = !!Flag.OTEL_EXPORTER_OTLP_ENDPOINT + const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT + export const enabled = !!base - export const layer = !Flag.OTEL_EXPORTER_OTLP_ENDPOINT - ? Layer.empty - : Otlp.layerJson({ - baseUrl: Flag.OTEL_EXPORTER_OTLP_ENDPOINT, - loggerMergeWithExisting: false, - resource: { - serviceName: "opencode", - serviceVersion: VERSION, - attributes: { - "deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL, - "opencode.client": Flag.OPENCODE_CLIENT, - }, + const resource = { + serviceName: "opencode", + serviceVersion: VERSION, + attributes: { + "deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL, + "opencode.client": Flag.OPENCODE_CLIENT, + }, + } + + const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS + ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce( + (acc, x) => { + const [key, value] = x.split("=") + acc[key] = value + return acc }, - headers: Flag.OTEL_EXPORTER_OTLP_HEADERS - ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce( - (acc, x) => { - const [key, value] = x.split("=") - acc[key] = value - return acc - }, - {} as Record, - ) - : undefined, - }).pipe(Layer.provide(FetchHttpClient.layer)) + {} as Record, + ) + : undefined + + export const layer = !base + ? EffectLogger.layer + : Otlp.layerJson({ + baseUrl: base, + loggerExportInterval: Duration.seconds(1), + loggerMergeWithExisting: true, + resource, + headers, + }).pipe(Layer.provide(EffectLogger.layer), Layer.provide(FetchHttpClient.layer)) } diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts index f609986b5..532278612 100644 --- a/packages/opencode/src/effect/run-service.ts +++ b/packages/opencode/src/effect/run-service.ts @@ -1,23 +1,25 @@ import { Effect, Layer, ManagedRuntime } from "effect" -import * as ServiceMap from "effect/ServiceMap" +import * as Context from "effect/Context" import { Instance } from "@/project/instance" -import { Context } from "@/util/context" -import { InstanceRef } from "./instance-ref" +import { LocalContext } from "@/util/local-context" +import { InstanceRef, WorkspaceRef } from "./instance-ref" import { Observability } from "./oltp" +import { WorkspaceContext } from "@/control-plane/workspace-context" export const memoMap = Layer.makeMemoMapUnsafe() -function attach(effect: Effect.Effect): Effect.Effect { +export function attach(effect: Effect.Effect): Effect.Effect { try { const ctx = Instance.current - return Effect.provideService(effect, InstanceRef, ctx) + const workspaceID = WorkspaceContext.workspaceID + return effect.pipe(Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, workspaceID)) } catch (err) { - if (!(err instanceof Context.NotFound)) throw err + if (!(err instanceof LocalContext.NotFound)) throw err } return effect } -export function makeRuntime(service: ServiceMap.Service, layer: Layer.Layer) { +export function makeRuntime(service: Context.Service, layer: Layer.Layer) { let rt: ManagedRuntime.ManagedRuntime | undefined const getRuntime = () => (rt ??= ManagedRuntime.make(Layer.merge(layer, Observability.layer), { memoMap })) diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index cb12b4c52..38c45a634 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -1,10 +1,10 @@ -import { Cause, Deferred, Effect, Exit, Fiber, Option, Schema, Scope, SynchronizedRef } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Schema, Scope, SynchronizedRef } from "effect" export interface Runner { readonly state: Runner.State readonly busy: boolean readonly ensureRunning: (work: Effect.Effect) => Effect.Effect - readonly startShell: (work: (signal: AbortSignal) => Effect.Effect) => Effect.Effect + readonly startShell: (work: Effect.Effect) => Effect.Effect readonly cancel: Effect.Effect } @@ -20,7 +20,6 @@ export namespace Runner { interface ShellHandle { id: number fiber: Fiber.Fiber - abort: AbortController } interface PendingHandle { @@ -100,13 +99,7 @@ export namespace Runner { }), ).pipe(Effect.flatten) - const stopShell = (shell: ShellHandle) => - Effect.gen(function* () { - shell.abort.abort() - const exit = yield* Fiber.await(shell.fiber).pipe(Effect.timeoutOption("100 millis")) - if (Option.isNone(exit)) yield* Fiber.interrupt(shell.fiber) - yield* Fiber.await(shell.fiber).pipe(Effect.exit, Effect.asVoid) - }) + const stopShell = (shell: ShellHandle) => Fiber.interrupt(shell.fiber) const ensureRunning = (work: Effect.Effect) => SynchronizedRef.modifyEffect( @@ -138,7 +131,7 @@ export namespace Runner { ), ) - const startShell = (work: (signal: AbortSignal) => Effect.Effect) => + const startShell = (work: Effect.Effect) => SynchronizedRef.modifyEffect( ref, Effect.fnUntraced(function* (st) { @@ -153,9 +146,8 @@ export namespace Runner { } yield* busy const id = next() - const abort = new AbortController() - const fiber = yield* work(abort.signal).pipe(Effect.ensuring(finishShell(id)), Effect.forkChild) - const shell = { id, fiber, abort } satisfies ShellHandle + const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild) + const shell = { id, fiber } satisfies ShellHandle return [ Effect.gen(function* () { const exit = yield* Fiber.await(fiber) diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index cdcf80a99..80ed2b7ef 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -3,7 +3,7 @@ import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@/filesystem" import { Git } from "@/git" -import { Effect, Layer, ServiceMap } from "effect" +import { Effect, Layer, Context } from "effect" import { formatPatch, structuredPatch } from "diff" import fuzzysort from "fuzzysort" import ignore from "ignore" @@ -11,7 +11,6 @@ import path from "path" import z from "zod" import { Global } from "../global" import { Instance } from "../project/instance" -import { Filesystem } from "../util/filesystem" import { Log } from "../util/log" import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" @@ -338,12 +337,13 @@ export namespace File { }) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/File") {} + export class Service extends Context.Service()("@opencode/File") {} export const layer = Layer.effect( Service, Effect.gen(function* () { const appFs = yield* AppFileSystem.Service + const git = yield* Git.Service const state = yield* InstanceState.make( Effect.fn("File.state")(() => @@ -410,6 +410,10 @@ export namespace File { cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void))) }) + const gitText = Effect.fnUntraced(function* (args: string[]) { + return (yield* git.run(args, { cwd: Instance.directory })).text() + }) + const init = Effect.fn("File.init")(function* () { yield* ensure() }) @@ -417,100 +421,87 @@ export namespace File { const status = Effect.fn("File.status")(function* () { if (Instance.project.vcs !== "git") return [] - return yield* Effect.promise(async () => { - const diffOutput = ( - await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { - cwd: Instance.directory, + const diffOutput = yield* gitText([ + "-c", + "core.fsmonitor=false", + "-c", + "core.quotepath=false", + "diff", + "--numstat", + "HEAD", + ]) + + const changed: File.Info[] = [] + + if (diffOutput.trim()) { + for (const line of diffOutput.trim().split("\n")) { + const [added, removed, file] = line.split("\t") + changed.push({ + path: file, + added: added === "-" ? 0 : parseInt(added, 10), + removed: removed === "-" ? 0 : parseInt(removed, 10), + status: "modified", }) - ).text() - - const changed: File.Info[] = [] - - if (diffOutput.trim()) { - for (const line of diffOutput.trim().split("\n")) { - const [added, removed, file] = line.split("\t") - changed.push({ - path: file, - added: added === "-" ? 0 : parseInt(added, 10), - removed: removed === "-" ? 0 : parseInt(removed, 10), - status: "modified", - }) - } } + } - const untrackedOutput = ( - await Git.run( - [ - "-c", - "core.fsmonitor=false", - "-c", - "core.quotepath=false", - "ls-files", - "--others", - "--exclude-standard", - ], - { - cwd: Instance.directory, - }, - ) - ).text() + const untrackedOutput = yield* gitText([ + "-c", + "core.fsmonitor=false", + "-c", + "core.quotepath=false", + "ls-files", + "--others", + "--exclude-standard", + ]) - if (untrackedOutput.trim()) { - for (const file of untrackedOutput.trim().split("\n")) { - try { - const content = await Filesystem.readText(path.join(Instance.directory, file)) - changed.push({ - path: file, - added: content.split("\n").length, - removed: 0, - status: "added", - }) - } catch { - continue - } - } + if (untrackedOutput.trim()) { + for (const file of untrackedOutput.trim().split("\n")) { + const content = yield* appFs + .readFileString(path.join(Instance.directory, file)) + .pipe(Effect.catch(() => Effect.succeed(undefined))) + if (content === undefined) continue + changed.push({ + path: file, + added: content.split("\n").length, + removed: 0, + status: "added", + }) } + } - const deletedOutput = ( - await Git.run( - [ - "-c", - "core.fsmonitor=false", - "-c", - "core.quotepath=false", - "diff", - "--name-only", - "--diff-filter=D", - "HEAD", - ], - { - cwd: Instance.directory, - }, - ) - ).text() + const deletedOutput = yield* gitText([ + "-c", + "core.fsmonitor=false", + "-c", + "core.quotepath=false", + "diff", + "--name-only", + "--diff-filter=D", + "HEAD", + ]) - if (deletedOutput.trim()) { - for (const file of deletedOutput.trim().split("\n")) { - changed.push({ - path: file, - added: 0, - removed: 0, - status: "deleted", - }) - } + if (deletedOutput.trim()) { + for (const file of deletedOutput.trim().split("\n")) { + changed.push({ + path: file, + added: 0, + removed: 0, + status: "deleted", + }) } + } - return changed.map((item) => { - const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path) - return { - ...item, - path: path.relative(Instance.directory, full), - } - }) + return changed.map((item) => { + const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path) + return { + ...item, + path: path.relative(Instance.directory, full), + } }) }) - const read = Effect.fn("File.read")(function* (file: string) { + const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) { using _ = log.time("read", { file }) const full = path.join(Instance.directory, file) @@ -558,27 +549,19 @@ export namespace File { ) if (Instance.project.vcs === "git") { - return yield* Effect.promise(async (): Promise => { - let diff = ( - await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory }) - ).text() - if (!diff.trim()) { - diff = ( - await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { - cwd: Instance.directory, - }) - ).text() - } - if (diff.trim()) { - const original = (await Git.run(["show", `HEAD:${file}`], { cwd: Instance.directory })).text() - const patch = structuredPatch(file, file, original, content, "old", "new", { - context: Infinity, - ignoreWhitespace: true, - }) - return { type: "text", content, patch, diff: formatPatch(patch) } - } - return { type: "text", content } - }) + let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file]) + if (!diff.trim()) { + diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file]) + } + if (diff.trim()) { + const original = yield* git.show(Instance.directory, "HEAD", file) + const patch = structuredPatch(file, file, original, content, "old", "new", { + context: Infinity, + ignoreWhitespace: true, + }) + return { type: "text" as const, content, patch, diff: formatPatch(patch) } + } + return { type: "text" as const, content } } return { type: "text" as const, content } @@ -660,7 +643,7 @@ export namespace File { }), ) - export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) + export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer)) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 601c82e94..4d0fc5598 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -3,10 +3,17 @@ import path from "path" import { Global } from "../global" import fs from "fs/promises" import z from "zod" +import { Effect, Layer, Context } from "effect" +import * as Stream from "effect/Stream" +import { ChildProcess } from "effect/unstable/process" +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import type { PlatformError } from "effect/PlatformError" import { NamedError } from "@opencode-ai/util/error" import { lazy } from "../util/lazy" import { Filesystem } from "../util/filesystem" +import { AppFileSystem } from "../filesystem" import { Process } from "../util/process" import { which } from "../util/which" import { text } from "node:stream/consumers" @@ -274,6 +281,69 @@ export namespace Ripgrep { input.signal?.throwIfAborted() } + export interface Interface { + readonly files: (input: { + cwd: string + glob?: string[] + hidden?: boolean + follow?: boolean + maxDepth?: number + }) => Stream.Stream + } + + export class Service extends Context.Service()("@opencode/Ripgrep") {} + + export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner + const afs = yield* AppFileSystem.Service + + const files = Effect.fn("Ripgrep.files")(function* (input: { + cwd: string + glob?: string[] + hidden?: boolean + follow?: boolean + maxDepth?: number + }) { + const rgPath = yield* Effect.promise(() => filepath()) + const isDir = yield* afs.isDir(input.cwd) + if (!isDir) { + return yield* Effect.die( + Object.assign(new Error(`No such file or directory: '${input.cwd}'`), { + code: "ENOENT" as const, + errno: -2, + path: input.cwd, + }), + ) + } + + const args = [rgPath, "--files", "--glob=!.git/*"] + if (input.follow) args.push("--follow") + if (input.hidden !== false) args.push("--hidden") + if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`) + if (input.glob) { + for (const g of input.glob) { + args.push(`--glob=${g}`) + } + } + + return spawner + .streamLines(ChildProcess.make(args[0], args.slice(1), { cwd: input.cwd })) + .pipe(Stream.filter((line: string) => line.length > 0)) + }) + + return Service.of({ + files: (input) => Stream.unwrap(files(input)), + }) + }), + ) + + export const defaultLayer = layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(CrossSpawnSpawner.defaultLayer), + ) + export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) { log.info("tree", input) const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal })) diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts index bd2b5f04f..e5055b671 100644 --- a/packages/opencode/src/file/time.ts +++ b/packages/opencode/src/file/time.ts @@ -1,6 +1,5 @@ -import { DateTime, Effect, Layer, Option, Semaphore, ServiceMap } from "effect" +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" @@ -34,10 +33,10 @@ export namespace FileTime { readonly read: (sessionID: SessionID, file: string) => Effect.Effect readonly get: (sessionID: SessionID, file: string) => Effect.Effect readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect - readonly withLock: (filepath: string, fn: () => Promise) => Effect.Effect + readonly withLock: (filepath: string, fn: () => Effect.Effect) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/FileTime") {} + export class Service extends Context.Service()("@opencode/FileTime") {} export const layer = Layer.effect( Service, @@ -46,7 +45,7 @@ export namespace FileTime { const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK const stamp = Effect.fnUntraced(function* (file: string) { - const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined))) + const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.void)) return { read: yield* DateTime.nowAsDate, mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined, @@ -103,8 +102,8 @@ export namespace FileTime { ) }) - const withLock = Effect.fn("FileTime.withLock")(function* (filepath: string, fn: () => Promise) { - return yield* Effect.promise(fn).pipe((yield* getLock(filepath)).withPermits(1)) + const withLock = Effect.fn("FileTime.withLock")(function* (filepath: string, fn: () => Effect.Effect) { + return yield* fn().pipe((yield* getLock(filepath)).withPermits(1)) }) return Service.of({ read, get, assert, withLock }) @@ -112,22 +111,4 @@ export namespace FileTime { ).pipe(Layer.orDie) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export function read(sessionID: SessionID, file: string) { - return runPromise((s) => s.read(sessionID, file)) - } - - export function get(sessionID: SessionID, file: string) { - return runPromise((s) => s.get(sessionID, file)) - } - - export async function assert(sessionID: SessionID, filepath: string) { - return runPromise((s) => s.assert(sessionID, filepath)) - } - - export async function withLock(filepath: string, fn: () => Promise): Promise { - return runPromise((s) => s.withLock(filepath, fn)) - } } diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index b78b3a33a..8737045c1 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Layer, Scope, ServiceMap } from "effect" +import { Cause, Effect, Layer, Scope, Context } from "effect" // @ts-ignore import { createWrapper } from "@parcel/watcher/wrapper" import type ParcelWatcher from "@parcel/watcher" @@ -8,7 +8,6 @@ import z from "zod" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" import { Flag } from "@/flag/flag" import { Git } from "@/git" import { Instance } from "@/project/instance" @@ -65,12 +64,13 @@ export namespace FileWatcher { readonly init: () => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/FileWatcher") {} + export class Service extends Context.Service()("@opencode/FileWatcher") {} export const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service + const git = yield* Git.Service const state = yield* InstanceState.make( Effect.fn("FileWatcher.state")( @@ -131,11 +131,9 @@ export namespace FileWatcher { } if (Instance.project.vcs === "git") { - const result = yield* Effect.promise(() => - Git.run(["rev-parse", "--git-dir"], { - cwd: Instance.project.worktree, - }), - ) + const result = yield* git.run(["rev-parse", "--git-dir"], { + cwd: Instance.project.worktree, + }) const vcsDir = result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { @@ -161,11 +159,5 @@ export namespace FileWatcher { }), ) - export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export function init() { - return runPromise((svc) => svc.init()) - } + export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer)) } diff --git a/packages/opencode/src/filesystem/index.ts b/packages/opencode/src/filesystem/index.ts index 01fdcd2e5..2c3964ec2 100644 --- a/packages/opencode/src/filesystem/index.ts +++ b/packages/opencode/src/filesystem/index.ts @@ -3,7 +3,7 @@ import { dirname, join, relative, resolve as pathResolve } from "path" import { realpathSync } from "fs" import * as NFS from "fs/promises" import { lookup } from "mime-types" -import { Effect, FileSystem, Layer, Schema, ServiceMap } from "effect" +import { Effect, FileSystem, Layer, Schema, Context } from "effect" import type { PlatformError } from "effect/PlatformError" import { Glob } from "../util/glob" @@ -36,7 +36,7 @@ export namespace AppFileSystem { readonly globMatch: (pattern: string, filepath: string) => boolean } - export class Service extends ServiceMap.Service()("@opencode/FileSystem") {} + export class Service extends Context.Service()("@opencode/FileSystem") {} export const layer = Layer.effect( Service, diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index c05c2bf45..1aeb2e51a 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -1,8 +1,7 @@ -import { Effect, Layer, ServiceMap } from "effect" +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" @@ -31,7 +30,7 @@ export namespace Format { readonly file: (filepath: string) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/Format") {} + export class Service extends Context.Service()("@opencode/Format") {} export const layer = Layer.effect( Service, @@ -51,6 +50,13 @@ export namespace Format { formatters[item.name] = item } for (const [name, item] of Object.entries(cfg.formatter ?? {})) { + // Ruff and uv are both the same formatter, so disabling either should disable both. + if (["ruff", "uv"].includes(name) && (cfg.formatter?.ruff?.disabled || cfg.formatter?.uv?.disabled)) { + // TODO combine formatters so shared backends like Ruff/uv don't need linked disable handling here. + delete formatters.ruff + delete formatters.uv + continue + } if (item.disabled) { delete formatters[name] continue @@ -186,18 +192,4 @@ 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)) - } } diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index 2b3a8a9b0..ac964ee0a 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -1,7 +1,6 @@ import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" -import { Effect, Layer, ServiceMap, Stream } from "effect" +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 = [ @@ -80,7 +79,7 @@ export namespace Git { return "modified" } - export class Service extends ServiceMap.Service()("@opencode/Git") {} + export class Service extends Context.Service()("@opencode/Git") {} export const layer = Layer.effect( Service, @@ -258,46 +257,4 @@ 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 branch(cwd: string) { - return runPromise((git) => git.branch(cwd)) - } - - export async function prefix(cwd: string) { - return runPromise((git) => git.prefix(cwd)) - } - - export async function defaultBranch(cwd: string) { - return runPromise((git) => git.defaultBranch(cwd)) - } - - export async function hasHead(cwd: string) { - return runPromise((git) => git.hasHead(cwd)) - } - - export async function mergeBase(cwd: string, base: string, head?: string) { - return runPromise((git) => git.mergeBase(cwd, base, head)) - } - - export async function show(cwd: string, ref: string, file: string, prefix?: string) { - return runPromise((git) => git.show(cwd, ref, file, prefix)) - } - - export async function status(cwd: string) { - return runPromise((git) => git.status(cwd)) - } - - export async function diff(cwd: string, ref: string) { - return runPromise((git) => git.diff(cwd, ref)) - } - - export async function stats(cwd: string, ref: string) { - return runPromise((git) => git.stats(cwd, ref)) - } } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 1fa027abf..753becc26 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -36,6 +36,7 @@ import { Database } from "./storage/db" import { errorMessage } from "./util/error" import { PluginCommand } from "./cli/cmd/plug" import { Heap } from "./cli/heap" +import { drizzle } from "drizzle-orm/bun-sqlite" process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { @@ -119,7 +120,7 @@ const cli = yargs(args) let last = -1 if (tty) process.stderr.write("\x1b[?25l") try { - await JsonMigration.run(Database.Client().$client, { + await JsonMigration.run(drizzle({ client: Database.Client().$client }), { progress: (event) => { const percent = Math.floor((event.current / event.total) * 100) if (percent === last && event.current !== event.total) return diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index f4cd4d09f..29f9bf1be 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -1,7 +1,6 @@ -import { Effect, Layer, Schema, ServiceMap, Stream } from "effect" +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" @@ -91,7 +90,7 @@ export namespace Installation { readonly upgrade: (method: Method, target: string) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/Installation") {} + export class Service extends Context.Service()("@opencode/Installation") {} export const layer: Layer.Layer = Layer.effect( @@ -338,18 +337,4 @@ export namespace Installation { Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), ) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function method(): Promise { - return runPromise((svc) => svc.method()) - } - - export async function latest(installMethod?: Method): Promise { - return runPromise((svc) => svc.latest(installMethod)) - } - - export async function upgrade(m: Method, target: string): Promise { - return runPromise((svc) => svc.upgrade(m, target)) - } } diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index de87e568f..8e34a8854 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -11,7 +11,7 @@ import { Instance } from "../project/instance" import { Flag } from "@/flag/flag" import { Process } from "../util/process" import { spawn as lspspawn } from "./launch" -import { Effect, Layer, ServiceMap } from "effect" +import { Effect, Layer, Context } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" @@ -156,7 +156,7 @@ export namespace LSP { readonly outgoingCalls: (input: LocInput) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/LSP") {} + export class Service extends Context.Service()("@opencode/LSP") {} export const layer = Layer.effect( Service, @@ -245,7 +245,7 @@ export namespace LSP { }) if (!handle) return undefined - log.info("spawned lsp server", { serverID: server.id }) + log.info("spawned lsp server", { serverID: server.id, root }) const client = await LSPClient.create({ serverID: server.id, @@ -540,6 +540,8 @@ export namespace LSP { export const outgoingCalls = async (input: LocInput) => runPromise((svc) => svc.outgoingCalls(input)) export namespace Diagnostic { + const MAX_PER_FILE = 20 + export function pretty(diagnostic: LSPClient.Diagnostic) { const severityMap = { 1: "ERROR", @@ -554,5 +556,14 @@ export namespace LSP { return `${severity} [${line}:${col}] ${diagnostic.message}` } + + export function report(file: string, issues: LSPClient.Diagnostic[]) { + const errors = issues.filter((item) => item.severity === 1) + if (errors.length === 0) return "" + const limited = errors.slice(0, MAX_PER_FILE) + const more = errors.length - MAX_PER_FILE + const suffix = more > 0 ? `\n... and ${more} more` : "" + return `\n${limited.map(pretty).join("\n")}${suffix}\n` + } } } diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 7421ed543..abfb31ead 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -105,17 +105,7 @@ export namespace LSPServer { if (!tsserver) return const bin = await Npm.which("typescript-language-server") if (!bin) return - - const args = ["--stdio", "--tsserver-log-verbosity", "off", "--tsserver-path", tsserver] - - if ( - !(await pathExists(path.join(root, "tsconfig.json"))) && - !(await pathExists(path.join(root, "jsconfig.json"))) - ) { - args.push("--ignore-node-modules") - } - - const proc = spawn(bin, args, { + const proc = spawn(bin, ["--stdio"], { cwd: root, env: { ...process.env, @@ -867,7 +857,7 @@ export namespace LSPServer { export const Clangd: Info = { id: "clangd", - root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd", "CMakeLists.txt", "Makefile"]), + root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd"]), extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"], async spawn(root) { const args = ["--background-index", "--clang-tidy"] diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index 773ca0a6f..7f33f32b8 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -1,7 +1,7 @@ import path from "path" import z from "zod" import { Global } from "../global" -import { Effect, Layer, ServiceMap } from "effect" +import { Effect, Layer, Context } from "effect" import { AppFileSystem } from "@/filesystem" import { makeRuntime } from "@/effect/run-service" @@ -49,7 +49,7 @@ export namespace McpAuth { readonly isTokenExpired: (mcpName: string) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/McpAuth") {} + export class Service extends Context.Service()("@opencode/McpAuth") {} export const layer = Layer.effect( Service, @@ -141,7 +141,7 @@ export namespace McpAuth { }), ) - const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) + export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 8c92bb6b2..696a662c1 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -24,7 +24,8 @@ import { BusEvent } from "../bus/bus-event" import { Bus } from "@/bus" import { TuiEvent } from "@/cli/cmd/tui/event" import open from "open" -import { Effect, Exit, Layer, Option, ServiceMap, Stream } from "effect" +import { Effect, Exit, Layer, Option, Context, Stream } from "effect" +import { EffectLogger } from "@/effect/logger" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" @@ -239,7 +240,7 @@ export namespace MCP { readonly getAuthStatus: (mcpName: string) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/MCP") {} + export class Service extends Context.Service()("@opencode/MCP") {} export const layer = Layer.effect( Service, @@ -286,6 +287,7 @@ export namespace MCP { clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async (url) => { @@ -468,12 +470,14 @@ export namespace MCP { log.info("tools list changed notification received", { server: name }) if (s.clients[name] !== client || s.status[name]?.status !== "connected") return - const listed = await Effect.runPromise(defs(name, client, timeout)) + const listed = await Effect.runPromise(defs(name, client, timeout).pipe(Effect.provide(EffectLogger.layer))) if (!listed) return if (s.clients[name] !== client || s.status[name]?.status !== "connected") return s.defs[name] = listed - await Effect.runPromise(bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore)) + await Effect.runPromise( + bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore, Effect.provide(EffectLogger.layer)), + ) }) } @@ -501,7 +505,7 @@ export namespace MCP { return } - const result = yield* create(key, mcp).pipe(Effect.catch(() => Effect.succeed(undefined))) + const result = yield* create(key, mcp).pipe(Effect.catch(() => Effect.void)) if (!result) return s.status[key] = result.status @@ -716,13 +720,16 @@ export namespace MCP { if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`) if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`) - yield* Effect.promise(() => McpOAuthCallback.ensureRunning()) + // OAuth config is optional - if not provided, we'll use auto-discovery + const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined + + // Start the callback server with custom redirectUri if configured + yield* Effect.promise(() => McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri)) const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32))) .map((b) => b.toString(16).padStart(2, "0")) .join("") yield* auth.updateOAuthState(mcpName, oauthState) - const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined let capturedUrl: URL | undefined const authProvider = new McpOAuthProvider( mcpName, @@ -731,6 +738,7 @@ export namespace MCP { clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async (url) => { @@ -901,9 +909,6 @@ export namespace MCP { export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name)) - export const getPrompt = async (clientName: string, name: string, args?: Record) => - runPromise((svc) => svc.getPrompt(clientName, name, args)) - export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName)) export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName)) diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index dd1d886fc..b5b6a7a6e 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -1,10 +1,14 @@ import { createConnection } from "net" import { createServer } from "http" import { Log } from "../util/log" -import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider" +import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider" const log = Log.create({ service: "mcp.oauth-callback" }) +// Current callback server configuration (may differ from defaults if custom redirectUri is used) +let currentPort = OAUTH_CALLBACK_PORT +let currentPath = OAUTH_CALLBACK_PATH + const HTML_SUCCESS = ` @@ -71,9 +75,9 @@ export namespace McpOAuthCallback { } function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) { - const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`) + const url = new URL(req.url || "/", `http://localhost:${currentPort}`) - if (url.pathname !== OAUTH_CALLBACK_PATH) { + if (url.pathname !== currentPath) { res.writeHead(404) res.end("Not found") return @@ -135,19 +139,31 @@ export namespace McpOAuthCallback { res.end(HTML_SUCCESS) } - export async function ensureRunning(): Promise { + export async function ensureRunning(redirectUri?: string): Promise { + // Parse the redirect URI to get port and path (uses defaults if not provided) + const { port, path } = parseRedirectUri(redirectUri) + + // If server is running on a different port/path, stop it first + if (server && (currentPort !== port || currentPath !== path)) { + log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port }) + await stop() + } + if (server) return - const running = await isPortInUse() + const running = await isPortInUse(port) if (running) { - log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT }) + log.info("oauth callback server already running on another instance", { port }) return } + currentPort = port + currentPath = path + server = createServer(handleRequest) await new Promise((resolve, reject) => { - server!.listen(OAUTH_CALLBACK_PORT, () => { - log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT }) + server!.listen(currentPort, () => { + log.info("oauth callback server started", { port: currentPort, path: currentPath }) resolve() }) server!.on("error", reject) @@ -182,9 +198,9 @@ export namespace McpOAuthCallback { } } - export async function isPortInUse(): Promise { + export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise { return new Promise((resolve) => { - const socket = createConnection(OAUTH_CALLBACK_PORT, "127.0.0.1") + const socket = createConnection(port, "127.0.0.1") socket.on("connect", () => { socket.destroy() resolve(true) diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index b4da73169..d675fc71e 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -17,6 +17,7 @@ export interface McpOAuthConfig { clientId?: string clientSecret?: string scope?: string + redirectUri?: string } export interface McpOAuthCallbacks { @@ -32,6 +33,9 @@ export class McpOAuthProvider implements OAuthClientProvider { ) {} get redirectUrl(): string { + if (this.config.redirectUri) { + return this.config.redirectUri + } return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}` } @@ -183,3 +187,22 @@ export class McpOAuthProvider implements OAuthClientProvider { } export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } + +/** + * Parse a redirect URI to extract port and path for the callback server. + * Returns defaults if the URI can't be parsed. + */ +export function parseRedirectUri(redirectUri?: string): { port: number; path: string } { + if (!redirectUri) { + return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH } + } + + try { + const url = new URL(redirectUri) + const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80 + const path = url.pathname || OAUTH_CALLBACK_PATH + return { port, path } + } catch { + return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH } + } +} diff --git a/packages/opencode/src/node.ts b/packages/opencode/src/node.ts index b0e653d6c..44a9f3b43 100644 --- a/packages/opencode/src/node.ts +++ b/packages/opencode/src/node.ts @@ -1 +1,6 @@ +export { Config } from "./config/config" export { Server } from "./server/server" +export { bootstrap } from "./cli/bootstrap" +export { Log } from "./util/log" +export { Database } from "./storage/db" +export { JsonMigration } from "./storage/json-migration" diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index b2cc0f9bb..a45aaf59d 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -10,7 +10,7 @@ import { PermissionTable } from "@/session/session.sql" import { Database, eq } from "@/storage/db" import { Log } from "@/util/log" import { Wildcard } from "@/util/wildcard" -import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" +import { Deferred, Effect, Layer, Schema, Context } from "effect" import os from "os" import z from "zod" import { evaluate as evalRule } from "./evaluate" @@ -135,7 +135,7 @@ export namespace Permission { return evalRule(permission, pattern, ...rulesets) } - export class Service extends ServiceMap.Service()("@opencode/Permission") {} + export class Service extends Context.Service()("@opencode/Permission") {} export const layer = Layer.effect( Service, diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts index bfa2b4957..2f1190a23 100644 --- a/packages/opencode/src/permission/schema.ts +++ b/packages/opencode/src/permission/schema.ts @@ -5,12 +5,8 @@ import { Identifier } from "@/id/id" import { Newtype } from "@/util/schema" export class PermissionID extends Newtype()("PermissionID", Schema.String) { - static make(id: string): PermissionID { - return this.makeUnsafe(id) - } - static ascending(id?: string): PermissionID { - return this.makeUnsafe(Identifier.ascending("permission", id)) + return this.make(Identifier.ascending("permission", id)) } static readonly zod = Identifier.schema("permission") as unknown as z.ZodType diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index bdeef9823..1e127fae5 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -376,9 +376,9 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { "gpt-5.4", "gpt-5.4-mini", ]) - for (const modelId of Object.keys(provider.models)) { + for (const [modelId, model] of Object.entries(provider.models)) { if (modelId.includes("codex")) continue - if (allowedModels.has(modelId)) continue + if (allowedModels.has(model.api.id)) continue delete provider.models[modelId] } diff --git a/packages/opencode/src/plugin/github-copilot/copilot.ts b/packages/opencode/src/plugin/github-copilot/copilot.ts index c0425b7ef..ac685f74d 100644 --- a/packages/opencode/src/plugin/github-copilot/copilot.ts +++ b/packages/opencode/src/plugin/github-copilot/copilot.ts @@ -5,6 +5,7 @@ import { iife } from "@/util/iife" import { Log } from "../../util/log" import { setTimeout as sleep } from "node:timers/promises" import { CopilotModels } from "./models" +import { MessageV2 } from "@/session/message-v2" const log = Log.create({ service: "plugin.copilot" }) @@ -27,11 +28,27 @@ function base(enterpriseUrl?: string) { return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com" } -function fix(model: Model): Model { +// Check if a message is a synthetic user msg used to attach an image from a tool call +function imgMsg(msg: any): boolean { + if (msg?.role !== "user") return false + + // Handle the 3 api formats + + const content = msg.content + if (typeof content === "string") return content === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT + if (!Array.isArray(content)) return false + return content.some( + (part: any) => + (part?.type === "text" || part?.type === "input_text") && part.text === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT, + ) +} + +function fix(model: Model, url: string): Model { return { ...model, api: { ...model.api, + url, npm: "@ai-sdk/github-copilot", }, } @@ -44,19 +61,23 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { id: "github-copilot", async models(provider, ctx) { if (ctx.auth?.type !== "oauth") { - return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model)])) + return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model, base())])) } + const auth = ctx.auth + return CopilotModels.get( - base(ctx.auth.enterpriseUrl), + base(auth.enterpriseUrl), { - Authorization: `Bearer ${ctx.auth.refresh}`, + Authorization: `Bearer ${auth.refresh}`, "User-Agent": `opencode/${Installation.VERSION}`, }, provider.models, ).catch((error) => { log.error("failed to fetch copilot models", { error }) - return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model)])) + return Object.fromEntries( + Object.entries(provider.models).map(([id, model]) => [id, fix(model, base(auth.enterpriseUrl))]), + ) }) }, }, @@ -66,10 +87,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { const info = await getAuth() if (!info || info.type !== "oauth") return {} - const baseURL = base(info.enterpriseUrl) - return { - baseURL, apiKey: "", async fetch(request: RequestInfo | URL, init?: RequestInit) { const info = await getAuth() @@ -88,7 +106,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { (msg: any) => Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"), ), - isAgent: last?.role !== "user", + isAgent: last?.role !== "user" || imgMsg(last), } } @@ -100,7 +118,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { (item: any) => Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"), ), - isAgent: last?.role !== "user", + isAgent: last?.role !== "user" || imgMsg(last), } } @@ -122,7 +140,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { part.content.some((nested: any) => nested?.type === "image")), ), ), - isAgent: !(last?.role === "user" && hasNonToolCalls), + isAgent: !(last?.role === "user" && hasNonToolCalls) || imgMsg(last), } } } catch {} diff --git a/packages/opencode/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts index b6b27d034..dfd6cecea 100644 --- a/packages/opencode/src/plugin/github-copilot/models.ts +++ b/packages/opencode/src/plugin/github-copilot/models.ts @@ -52,13 +52,15 @@ export namespace CopilotModels { (remote.capabilities.supports.vision ?? false) || (remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/")) + const isMsgApi = remote.supported_endpoints?.includes("/v1/messages") + return { id: key, providerID: "github-copilot", api: { id: remote.id, - url, - npm: "@ai-sdk/github-copilot", + url: isMsgApi ? `${url}/v1` : url, + npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot", }, // API response wins status: "active", diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index d84d1cc7b..e0478e0b3 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -11,7 +11,8 @@ import { CopilotAuthPlugin } from "./github-copilot/copilot" import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { PoeAuthPlugin } from "opencode-poe-auth" import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" -import { Effect, Layer, ServiceMap, Stream } from "effect" +import { Effect, Layer, Context, Stream } from "effect" +import { EffectLogger } from "@/effect/logger" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { errorMessage } from "@/util/error" @@ -44,7 +45,7 @@ export namespace Plugin { readonly init: () => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/Plugin") {} + export class Service extends Context.Service()("@opencode/Plugin") {} // Built-in plugins that are directly imported (not installed from npm) const INTERNAL_PLUGINS: PluginInstance[] = [ @@ -83,7 +84,11 @@ export namespace Plugin { } function publishPluginError(bus: Bus.Interface, message: string) { - Effect.runFork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })) + Effect.runFork( + bus + .publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + .pipe(Effect.provide(EffectLogger.layer)), + ) } async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) { @@ -119,7 +124,7 @@ export namespace Plugin { Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`, } : undefined, - fetch: async (...args) => Server.Default().fetch(...args), + fetch: async (...args) => (await Server.Default()).app.fetch(...args), }) const cfg = yield* config.get() const input: PluginInput = { @@ -205,13 +210,15 @@ export namespace Plugin { return message }, }).pipe( - Effect.catch((message) => - bus.publish(Session.Event.Error, { - error: new NamedError.Unknown({ - message: `Failed to load plugin ${load.spec}: ${message}`, - }).toObject(), - }), - ), + Effect.catch(() => { + // TODO: make proper events for this + // bus.publish(Session.Event.Error, { + // error: new NamedError.Unknown({ + // message: `Failed to load plugin ${load.spec}: ${message}`, + // }).toObject(), + // }) + return Effect.void + }), ) } diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index a8ad84297..b7d739fcd 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -2,7 +2,6 @@ import { Plugin } from "../plugin" import { Format } from "../format" import { LSP } from "../lsp" import { File } from "../file" -import { FileWatcher } from "../file/watcher" import { Snapshot } from "../snapshot" import { Project } from "./project" import { Vcs } from "./vcs" @@ -10,16 +9,18 @@ import { Bus } from "../bus" import { Command } from "../command" import { Instance } from "./instance" import { Log } from "@/util/log" +import { BootstrapRuntime } from "@/effect/bootstrap-runtime" +import { FileWatcher } from "@/file/watcher" import { ShareNext } from "@/share/share-next" export async function InstanceBootstrap() { Log.Default.info("bootstrapping", { directory: Instance.directory }) await Plugin.init() - ShareNext.init() - Format.init() + void BootstrapRuntime.runPromise(ShareNext.Service.use((svc) => svc.init())) + void BootstrapRuntime.runPromise(Format.Service.use((svc) => svc.init())) await LSP.init() File.init() - FileWatcher.init() + void BootstrapRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init())) Vcs.init() Snapshot.init() diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index a0d6f2414..8d2d51db6 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -3,8 +3,9 @@ import { disposeInstance } from "@/effect/instance-registry" import { Filesystem } from "@/util/filesystem" import { iife } from "@/util/iife" import { Log } from "@/util/log" -import { Context } from "../util/context" +import { LocalContext } from "../util/local-context" import { Project } from "./project" +import { WorkspaceContext } from "@/control-plane/workspace-context" import { State } from "./state" export interface InstanceContext { @@ -13,26 +14,16 @@ export interface InstanceContext { project: Project.Info } -const context = Context.create("instance") +const context = LocalContext.create("instance") const cache = new Map>() const disposal = { all: undefined as Promise | undefined, } -function emit(directory: string) { - GlobalBus.emit("event", { - directory, - payload: { - type: "server.instance.disposed", - properties: { - directory, - }, - }, - }) -} +function emitDisposed(directory: string) {} -function boot(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { +function boot(input: { directory: string; init?: () => Promise; worktree?: string; project?: Project.Info }) { return iife(async () => { const ctx = input.project && input.worktree @@ -93,17 +84,19 @@ export const Instance = { get project() { return context.use().project }, + /** * Check if a path is within the project boundary. * Returns true if path is inside Instance.directory OR Instance.worktree. * Paths within the worktree but outside the working directory should not trigger external_directory permission. */ - containsPath(filepath: string) { - if (Filesystem.contains(Instance.directory, filepath)) return true + containsPath(filepath: string, ctx?: InstanceContext) { + const instance = ctx ?? Instance + if (Filesystem.contains(instance.directory, filepath)) return true // Non-git projects set worktree to "/" which would match ANY absolute path. // Skip worktree check in this case to preserve external_directory permissions. if (Instance.worktree === "/") return false - return Filesystem.contains(Instance.worktree, filepath) + return Filesystem.contains(instance.worktree, filepath) }, /** * Captures the current instance ALS context and returns a wrapper that @@ -131,15 +124,39 @@ export const Instance = { await Promise.all([State.dispose(directory), disposeInstance(directory)]) cache.delete(directory) const next = track(directory, boot({ ...input, directory })) - emit(directory) + + GlobalBus.emit("event", { + directory, + project: input.project?.id, + workspace: WorkspaceContext.workspaceID, + payload: { + type: "server.instance.disposed", + properties: { + directory, + }, + }, + }) + return await next }, async dispose() { const directory = Instance.directory + const project = Instance.project Log.Default.info("disposing instance", { directory }) await Promise.all([State.dispose(directory), disposeInstance(directory)]) cache.delete(directory) - emit(directory) + + GlobalBus.emit("event", { + directory, + project: project.id, + workspace: WorkspaceContext.workspaceID, + payload: { + type: "server.instance.disposed", + properties: { + directory, + }, + }, + }) }, async disposeAll() { if (disposal.all) return disposal.all diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index f4b8b940d..df07ca221 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -8,7 +8,7 @@ import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { which } from "../util/which" import { ProjectID } from "./schema" -import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect" +import { Effect, Layer, Path, Scope, Context, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { makeRuntime } from "@/effect/run-service" @@ -100,7 +100,7 @@ export namespace Project { readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/Project") {} + export class Service extends Context.Service()("@opencode/Project") {} type GitResult = { code: number; text: string; stderr: string } @@ -137,6 +137,8 @@ export namespace Project { const emitUpdated = (data: Info) => Effect.sync(() => GlobalBus.emit("event", { + directory: "global", + project: data.id, payload: { type: Event.Updated.type, properties: data }, }), ) @@ -158,7 +160,7 @@ export namespace Project { return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe( Effect.map((x) => x.trim()), Effect.map(ProjectID.make), - Effect.catch(() => Effect.succeed(undefined)), + Effect.catch(() => Effect.void), ) }) diff --git a/packages/opencode/src/project/schema.ts b/packages/opencode/src/project/schema.ts index e904ff5a8..d10c82e2c 100644 --- a/packages/opencode/src/project/schema.ts +++ b/packages/opencode/src/project/schema.ts @@ -9,8 +9,7 @@ export type ProjectID = typeof projectIdSchema.Type export const ProjectID = projectIdSchema.pipe( withStatics((schema: typeof projectIdSchema) => ({ - global: schema.makeUnsafe("global"), - make: (id: string) => schema.makeUnsafe(id), + global: schema.make("global"), zod: z.string().pipe(z.custom()), })), ) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index ec6e415c8..1b1f21f90 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, ServiceMap, Stream } from "effect" +import { Effect, Layer, Context, Stream } from "effect" import { formatPatch, structuredPatch } from "diff" import path from "path" import { Bus } from "@/bus" @@ -151,7 +151,7 @@ export namespace Vcs { root: Git.Base | undefined } - export class Service extends ServiceMap.Service()("@opencode/Vcs") {} + export class Service extends Context.Service()("@opencode/Vcs") {} export const layer: Layer.Layer = Layer.effect( Service, @@ -161,39 +161,37 @@ export namespace Vcs { const bus = yield* Bus.Service const state = yield* InstanceState.make( - Effect.fn("Vcs.state")((ctx) => - Effect.gen(function* () { - if (ctx.project.vcs !== "git") { - return { current: undefined, root: undefined } - } + Effect.fn("Vcs.state")(function* (ctx) { + if (ctx.project.vcs !== "git") { + return { current: undefined, root: undefined } + } - const get = Effect.fnUntraced(function* () { - return yield* git.branch(ctx.directory) - }) - const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], { - concurrency: 2, - }) - const value = { current, root } - log.info("initialized", { branch: value.current, default_branch: value.root?.name }) + const get = Effect.fnUntraced(function* () { + return yield* git.branch(ctx.directory) + }) + const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], { + concurrency: 2, + }) + const value = { current, root } + log.info("initialized", { branch: value.current, default_branch: value.root?.name }) - yield* bus.subscribe(FileWatcher.Event.Updated).pipe( - Stream.filter((evt) => evt.properties.file.endsWith("HEAD")), - Stream.runForEach((_evt) => - Effect.gen(function* () { - const next = yield* get() - if (next !== value.current) { - log.info("branch changed", { from: value.current, to: next }) - value.current = next - yield* bus.publish(Event.BranchUpdated, { branch: next }) - } - }), - ), - Effect.forkScoped, - ) + yield* bus.subscribe(FileWatcher.Event.Updated).pipe( + Stream.filter((evt) => evt.properties.file.endsWith("HEAD")), + Stream.runForEach((_evt) => + Effect.gen(function* () { + const next = yield* get() + if (next !== value.current) { + log.info("branch changed", { from: value.current, to: next }) + value.current = next + yield* bus.publish(Event.BranchUpdated, { branch: next }) + } + }), + ), + Effect.forkScoped, + ) - return value - }), - ), + return value + }), ) return Service.of({ @@ -228,7 +226,7 @@ export namespace Vcs { }), ) - const defaultLayer = layer.pipe( + export const defaultLayer = layer.pipe( Layer.provide(Git.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Bus.layer), diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 38ef4b11f..e410b8636 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -2,10 +2,9 @@ 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, ServiceMap } from "effect" +import { Array as Arr, Effect, Layer, Record, Result, Context } from "effect" import z from "zod" export namespace ProviderAuth { @@ -109,7 +108,7 @@ export namespace ProviderAuth { pending: Map } - export class Service extends ServiceMap.Service()("@opencode/ProviderAuth") {} + export class Service extends Context.Service()("@opencode/ProviderAuth") {} export const layer: Layer.Layer = Layer.effect( Service, @@ -232,22 +231,4 @@ 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 - }): Promise { - return runPromise((svc) => svc.authorize(input)) - } - - export async function callback(input: { providerID: ProviderID; method: number; code?: string }) { - return runPromise((svc) => svc.callback(input)) - } } diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index c6ab5d836..2d787588b 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -22,6 +22,27 @@ export namespace ModelsDev { ) const ttl = 5 * 60 * 1000 + type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[] + + const JsonValue: z.ZodType = z.lazy(() => + z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(JsonValue), z.record(z.string(), JsonValue)]), + ) + + const Cost = z.object({ + input: z.number(), + output: z.number(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + context_over_200k: z + .object({ + input: z.number(), + output: z.number(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + }) + .optional(), + }) + export const Model = z.object({ id: z.string(), name: z.string(), @@ -41,22 +62,7 @@ export namespace ModelsDev { .strict(), ]) .optional(), - cost: z - .object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - context_over_200k: z - .object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - }) - .optional(), - }) - .optional(), + cost: Cost.optional(), limit: z.object({ context: z.number(), input: z.number().optional(), @@ -68,12 +74,26 @@ export namespace ModelsDev { output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), }) .optional(), - experimental: z.boolean().optional(), + experimental: z + .object({ + modes: z + .record( + z.string(), + z.object({ + cost: Cost.optional(), + provider: z + .object({ + body: z.record(z.string(), JsonValue).optional(), + headers: z.record(z.string(), z.string()).optional(), + }) + .optional(), + }), + ) + .optional(), + }) + .optional(), status: z.enum(["alpha", "beta", "deprecated"]).optional(), - options: z.record(z.string(), z.any()), - headers: z.record(z.string(), z.string()).optional(), provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(), - variants: z.record(z.string(), z.record(z.string(), z.any())).optional(), }) export type Model = z.infer diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 9ca49bf8f..e401a067c 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -19,7 +19,8 @@ import { iife } from "@/util/iife" import { Global } from "../global" import path from "path" import { Filesystem } from "../util/filesystem" -import { Effect, Layer, ServiceMap } from "effect" +import { Effect, Layer, Context } from "effect" +import { EffectLogger } from "@/effect/logger" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" @@ -574,6 +575,7 @@ export namespace Provider { const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow" const model = sdk.workflowChat(sdkModelID, { featureFlags, + workflowDefinition: options?.workflowDefinition as string | undefined, }) if (workflowRef) { model.selectedModelRef = workflowRef @@ -923,7 +925,29 @@ export namespace Provider { varsLoaders: Record } - export class Service extends ServiceMap.Service()("@opencode/Provider") {} + export class Service extends Context.Service()("@opencode/Provider") {} + + function cost(c: ModelsDev.Model["cost"]): Model["cost"] { + const result: Model["cost"] = { + input: c?.input ?? 0, + output: c?.output ?? 0, + cache: { + read: c?.cache_read ?? 0, + write: c?.cache_write ?? 0, + }, + } + if (c?.context_over_200k) { + result.experimentalOver200K = { + cache: { + read: c.context_over_200k.cache_read ?? 0, + write: c.context_over_200k.cache_write ?? 0, + }, + input: c.context_over_200k.input, + output: c.context_over_200k.output, + } + } + return result + } function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model { const m: Model = { @@ -937,26 +961,9 @@ export namespace Provider { npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible", }, status: model.status ?? "active", - headers: model.headers ?? {}, - options: model.options ?? {}, - cost: { - input: model.cost?.input ?? 0, - output: model.cost?.output ?? 0, - cache: { - read: model.cost?.cache_read ?? 0, - write: model.cost?.cache_write ?? 0, - }, - experimentalOver200K: model.cost?.context_over_200k - ? { - cache: { - read: model.cost.context_over_200k.cache_read ?? 0, - write: model.cost.context_over_200k.cache_write ?? 0, - }, - input: model.cost.context_over_200k.input, - output: model.cost.context_over_200k.output, - } - : undefined, - }, + headers: {}, + options: {}, + cost: cost(model.cost), limit: { context: model.limit.context, input: model.limit.input, @@ -993,13 +1000,31 @@ export namespace Provider { } export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { + const models: Record = {} + for (const [key, model] of Object.entries(provider.models)) { + models[key] = fromModelsDevModel(provider, model) + for (const [mode, opts] of Object.entries(model.experimental?.modes ?? {})) { + const id = `${model.id}-${mode}` + const m = fromModelsDevModel(provider, model) + m.id = ModelID.make(id) + m.name = `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}` + if (opts.cost) m.cost = mergeDeep(m.cost, cost(opts.cost)) + // convert body params to camelCase for ai sdk compatibility + if (opts.provider?.body) + m.options = Object.fromEntries( + Object.entries(opts.provider.body).map(([k, v]) => [k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()), v]), + ) + if (opts.provider?.headers) m.headers = opts.provider.headers + models[id] = m + } + } return { id: ProviderID.make(provider.id), source: "custom", name: provider.name, env: provider.env ?? [], options: {}, - models: mapValues(provider.models, (model) => fromModelsDevModel(provider, model)), + models, } } @@ -1191,7 +1216,8 @@ export namespace Provider { const options = yield* Effect.promise(() => plugin.auth!.loader!( - () => Effect.runPromise(auth.get(providerID).pipe(Effect.orDie)) as any, + () => + Effect.runPromise(auth.get(providerID).pipe(Effect.orDie, Effect.provide(EffectLogger.layer))) as any, database[plugin.auth!.provider], ), ) diff --git a/packages/opencode/src/provider/schema.ts b/packages/opencode/src/provider/schema.ts index 71c8a1029..4490ca289 100644 --- a/packages/opencode/src/provider/schema.ts +++ b/packages/opencode/src/provider/schema.ts @@ -9,20 +9,19 @@ export type ProviderID = typeof providerIdSchema.Type export const ProviderID = providerIdSchema.pipe( withStatics((schema: typeof providerIdSchema) => ({ - make: (id: string) => schema.makeUnsafe(id), zod: z.string().pipe(z.custom()), // Well-known providers - opencode: schema.makeUnsafe("opencode"), - anthropic: schema.makeUnsafe("anthropic"), - openai: schema.makeUnsafe("openai"), - google: schema.makeUnsafe("google"), - googleVertex: schema.makeUnsafe("google-vertex"), - githubCopilot: schema.makeUnsafe("github-copilot"), - amazonBedrock: schema.makeUnsafe("amazon-bedrock"), - azure: schema.makeUnsafe("azure"), - openrouter: schema.makeUnsafe("openrouter"), - mistral: schema.makeUnsafe("mistral"), - gitlab: schema.makeUnsafe("gitlab"), + opencode: schema.make("opencode"), + anthropic: schema.make("anthropic"), + openai: schema.make("openai"), + google: schema.make("google"), + googleVertex: schema.make("google-vertex"), + githubCopilot: schema.make("github-copilot"), + amazonBedrock: schema.make("amazon-bedrock"), + azure: schema.make("azure"), + openrouter: schema.make("openrouter"), + mistral: schema.make("mistral"), + gitlab: schema.make("gitlab"), })), ) @@ -32,7 +31,6 @@ export type ModelID = typeof modelIdSchema.Type export const ModelID = modelIdSchema.pipe( withStatics((schema: typeof modelIdSchema) => ({ - make: (id: string) => schema.makeUnsafe(id), zod: z.string().pipe(z.custom()), })), ) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index f536e04bf..dea8cf936 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -376,7 +376,8 @@ export namespace ProviderTransform { id.includes("mistral") || id.includes("kimi") || id.includes("k2p5") || - id.includes("qwen") + id.includes("qwen") || + id.includes("big-pickle") ) return {} @@ -773,7 +774,10 @@ export namespace ProviderTransform { result["chat_template_args"] = { enable_thinking: true } } - if (["zai", "zhipuai"].includes(input.model.providerID) && input.model.api.npm === "@ai-sdk/openai-compatible") { + if ( + ["zai", "zhipuai"].some((id) => input.model.providerID.includes(id)) && + input.model.api.npm === "@ai-sdk/openai-compatible" + ) { result["thinking"] = { type: "enabled", clear_thinking: false, diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 0321b9800..a563bb954 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -10,7 +10,8 @@ import { lazy } from "@opencode-ai/util/lazy" import { Shell } from "@/shell/shell" import { Plugin } from "@/plugin" import { PtyID } from "./schema" -import { Effect, Layer, ServiceMap } from "effect" +import { Effect, Layer, Context } from "effect" +import { EffectLogger } from "@/effect/logger" export namespace Pty { const log = Log.create({ service: "pty" }) @@ -112,7 +113,7 @@ export namespace Pty { ) => Effect.Effect<{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined> } - export class Service extends ServiceMap.Service()("@opencode/Pty") {} + export class Service extends Context.Service()("@opencode/Pty") {} export const layer = Layer.effect( Service, @@ -256,8 +257,8 @@ export namespace Pty { if (session.info.status === "exited") return log.info("session exited", { id, exitCode }) session.info.status = "exited" - Effect.runFork(bus.publish(Event.Exited, { id, exitCode })) - Effect.runFork(remove(id)) + Effect.runFork(bus.publish(Event.Exited, { id, exitCode }).pipe(Effect.provide(EffectLogger.layer))) + Effect.runFork(remove(id).pipe(Effect.provide(EffectLogger.layer))) }), ) yield* bus.publish(Event.Created, { info }) @@ -359,7 +360,7 @@ export namespace Pty { }), ) - const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer)) + export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer)) const { runPromise } = makeRuntime(Service, defaultLayer) @@ -371,10 +372,6 @@ export namespace Pty { return runPromise((svc) => svc.get(id)) } - export async function resize(id: PtyID, cols: number, rows: number) { - return runPromise((svc) => svc.resize(id, cols, rows)) - } - export async function write(id: PtyID, data: string) { return runPromise((svc) => svc.write(id, data)) } diff --git a/packages/opencode/src/pty/schema.ts b/packages/opencode/src/pty/schema.ts index 47b3196f0..deb498891 100644 --- a/packages/opencode/src/pty/schema.ts +++ b/packages/opencode/src/pty/schema.ts @@ -10,8 +10,7 @@ export type PtyID = typeof ptyIdSchema.Type export const PtyID = ptyIdSchema.pipe( withStatics((schema: typeof ptyIdSchema) => ({ - make: (id: string) => schema.makeUnsafe(id), - ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("pty", id)), + ascending: (id?: string) => schema.make(Identifier.ascending("pty", id)), zod: Identifier.schema("pty").pipe(z.custom()), })), ) diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 615c699ce..178bc7943 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -1,8 +1,7 @@ -import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" +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" @@ -104,7 +103,7 @@ export namespace Question { readonly list: () => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/Question") {} + export class Service extends Context.Service()("@opencode/Question") {} export const layer = Layer.effect( Service, @@ -199,26 +198,4 @@ export namespace Question { ) export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function ask(input: { - sessionID: SessionID - questions: Info[] - tool?: { messageID: MessageID; callID: string } - }): Promise { - 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()) - } } diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts index 38b930af1..e5a0496c9 100644 --- a/packages/opencode/src/question/schema.ts +++ b/packages/opencode/src/question/schema.ts @@ -5,12 +5,8 @@ import { Identifier } from "@/id/id" import { Newtype } from "@/util/schema" export class QuestionID extends Newtype()("QuestionID", Schema.String) { - static make(id: string): QuestionID { - return this.makeUnsafe(id) - } - static ascending(id?: string): QuestionID { - return this.makeUnsafe(Identifier.ascending("question", id)) + return this.make(Identifier.ascending("question", id)) } static readonly zod = Identifier.schema("question") as unknown as z.ZodType diff --git a/packages/opencode/src/server/adapter.bun.ts b/packages/opencode/src/server/adapter.bun.ts new file mode 100644 index 000000000..3e70b97e8 --- /dev/null +++ b/packages/opencode/src/server/adapter.bun.ts @@ -0,0 +1,40 @@ +import type { Hono } from "hono" +import { createBunWebSocket } from "hono/bun" +import type { Adapter } from "./adapter" + +export const adapter: Adapter = { + create(app: Hono) { + const ws = createBunWebSocket() + return { + upgradeWebSocket: ws.upgradeWebSocket, + async listen(opts) { + const args = { + fetch: app.fetch, + hostname: opts.hostname, + idleTimeout: 0, + websocket: ws.websocket, + } as const + const start = (port: number) => { + try { + return Bun.serve({ ...args, port }) + } catch { + return + } + } + const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port) + if (!server) { + throw new Error(`Failed to start server on port ${opts.port}`) + } + if (!server.port) { + throw new Error(`Failed to resolve server address for port ${opts.port}`) + } + return { + port: server.port, + stop(close?: boolean) { + return Promise.resolve(server.stop(close)) + }, + } + }, + } + }, +} diff --git a/packages/opencode/src/server/adapter.node.ts b/packages/opencode/src/server/adapter.node.ts new file mode 100644 index 000000000..9c2a41cce --- /dev/null +++ b/packages/opencode/src/server/adapter.node.ts @@ -0,0 +1,66 @@ +import { createAdaptorServer, type ServerType } from "@hono/node-server" +import { createNodeWebSocket } from "@hono/node-ws" +import type { Hono } from "hono" +import type { Adapter } from "./adapter" + +export const adapter: Adapter = { + create(app: Hono) { + const ws = createNodeWebSocket({ app }) + return { + upgradeWebSocket: ws.upgradeWebSocket, + async listen(opts) { + const start = (port: number) => + new Promise((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 | 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 + }, + } + }, + } + }, +} diff --git a/packages/opencode/src/server/adapter.ts b/packages/opencode/src/server/adapter.ts new file mode 100644 index 000000000..272521d7d --- /dev/null +++ b/packages/opencode/src/server/adapter.ts @@ -0,0 +1,21 @@ +import type { Hono } from "hono" +import type { UpgradeWebSocket } from "hono/ws" + +export type Opts = { + port: number + hostname: string +} + +export type Listener = { + port: number + stop: (close?: boolean) => Promise +} + +export interface Runtime { + upgradeWebSocket: UpgradeWebSocket + listen(opts: Opts): Promise +} + +export interface Adapter { + create(app: Hono): Runtime +} diff --git a/packages/opencode/src/server/control/index.ts b/packages/opencode/src/server/control/index.ts new file mode 100644 index 000000000..aae77f2f0 --- /dev/null +++ b/packages/opencode/src/server/control/index.ts @@ -0,0 +1,150 @@ +import { Auth } from "@/auth" +import { Log } from "@/util/log" +import { ProviderID } from "@/provider/schema" +import { Hono } from "hono" +import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi" +import z from "zod" +import { errors } from "../error" +import { GlobalRoutes } from "../instance/global" + +export function ControlPlaneRoutes(): Hono { + const app = new Hono() + return app + .route("/global", GlobalRoutes()) + .put( + "/auth/:providerID", + describeRoute({ + summary: "Set auth credentials", + description: "Set authentication credentials", + operationId: "auth.set", + responses: { + 200: { + description: "Successfully set authentication credentials", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: ProviderID.zod, + }), + ), + validator("json", Auth.Info.zod), + async (c) => { + const providerID = c.req.valid("param").providerID + const info = c.req.valid("json") + await Auth.set(providerID, info) + return c.json(true) + }, + ) + .delete( + "/auth/:providerID", + describeRoute({ + summary: "Remove auth credentials", + description: "Remove authentication credentials", + operationId: "auth.remove", + responses: { + 200: { + description: "Successfully removed authentication credentials", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: ProviderID.zod, + }), + ), + async (c) => { + const providerID = c.req.valid("param").providerID + await Auth.remove(providerID) + return c.json(true) + }, + ) + .get( + "/doc", + openAPIRouteHandler(app, { + documentation: { + info: { + title: "opencode", + version: "0.0.3", + description: "opencode api", + }, + openapi: "3.1.1", + }, + }), + ) + .use( + validator( + "query", + z.object({ + directory: z.string().optional(), + workspace: z.string().optional(), + }), + ), + ) + .post( + "/log", + describeRoute({ + summary: "Write log", + description: "Write a log entry to the server logs with specified level and metadata.", + operationId: "app.log", + responses: { + 200: { + description: "Log entry written successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + service: z.string().meta({ description: "Service name for the log entry" }), + level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }), + message: z.string().meta({ description: "Log message" }), + extra: z + .record(z.string(), z.any()) + .optional() + .meta({ description: "Additional metadata for the log entry" }), + }), + ), + async (c) => { + const { service, level, message, extra } = c.req.valid("json") + const logger = Log.create({ service }) + + switch (level) { + case "debug": + logger.debug(message, extra) + break + case "info": + logger.info(message, extra) + break + case "error": + logger.error(message, extra) + break + case "warn": + logger.warn(message, extra) + break + } + + return c.json(true) + }, + ) +} diff --git a/packages/opencode/src/server/routes/config.ts b/packages/opencode/src/server/instance/config.ts similarity index 100% rename from packages/opencode/src/server/routes/config.ts rename to packages/opencode/src/server/instance/config.ts diff --git a/packages/opencode/src/server/routes/event.ts b/packages/opencode/src/server/instance/event.ts similarity index 100% rename from packages/opencode/src/server/routes/event.ts rename to packages/opencode/src/server/instance/event.ts diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/instance/experimental.ts similarity index 90% rename from packages/opencode/src/server/routes/experimental.ts rename to packages/opencode/src/server/instance/experimental.ts index 63f15d643..4eef56176 100644 --- a/packages/opencode/src/server/routes/experimental.ts +++ b/packages/opencode/src/server/instance/experimental.ts @@ -11,9 +11,11 @@ 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 { PushRelay } from "../push-relay" import * as QRCode from "qrcode" @@ -96,11 +98,20 @@ export const ExperimentalRoutes = lazy(() => }, }), async (c) => { - const [consoleState, groups] = await Promise.all([Config.getConsoleState(), Account.orgsByAccount()]) - return c.json({ - ...consoleState, - switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0), - }) + 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) }, ) .get( @@ -121,17 +132,24 @@ export const ExperimentalRoutes = lazy(() => }, }), async (c) => { - 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, - })), + 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, + })), + ) + }), ) return c.json({ orgs }) }, @@ -156,7 +174,12 @@ export const ExperimentalRoutes = lazy(() => validator("json", ConsoleSwitchBody), async (c) => { const body = c.req.valid("json") - await Account.switchOrg(AccountID.make(body.accountID), OrgID.make(body.orgID)) + await AppRuntime.runPromise( + Effect.gen(function* () { + const account = yield* Account.Service + yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID))) + }), + ) return c.json(true) }, ) diff --git a/packages/opencode/src/server/routes/file.ts b/packages/opencode/src/server/instance/file.ts similarity index 100% rename from packages/opencode/src/server/routes/file.ts rename to packages/opencode/src/server/instance/file.ts diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/instance/global.ts similarity index 83% rename from packages/opencode/src/server/routes/global.ts rename to packages/opencode/src/server/instance/global.ts index 16b9e559f..6b0a9a164 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/instance/global.ts @@ -1,10 +1,12 @@ 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" @@ -105,6 +107,8 @@ export const GlobalRoutes = lazy(() => z .object({ directory: z.string(), + project: z.string().optional(), + workspace: z.string().optional(), payload: BusEvent.payloads(), }) .meta({ @@ -288,25 +292,41 @@ export const GlobalRoutes = lazy(() => }), ), async (c) => { - const method = await Installation.method() - if (method === "unknown") { - return c.json({ success: false, error: "Unknown installation method" }, 400) + 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 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) + 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 }) }, ), ) diff --git a/packages/opencode/src/server/instance.ts b/packages/opencode/src/server/instance/index.ts similarity index 64% rename from packages/opencode/src/server/instance.ts rename to packages/opencode/src/server/instance/index.ts index 65ea2fac2..2acc424e4 100644 --- a/packages/opencode/src/server/instance.ts +++ b/packages/opencode/src/server/instance/index.ts @@ -1,50 +1,33 @@ import { describeRoute, resolver, validator } from "hono-openapi" import { Hono } from "hono" -import { proxy } from "hono/proxy" import type { UpgradeWebSocket } from "hono/ws" import z from "zod" -import { createHash } from "node:crypto" -import { 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 { Format } from "../../format" +import { TuiRoutes } from "./tui" +import { Instance } from "../../project/instance" +import { Vcs } from "../../project/vcs" +import { Agent } from "../../agent/agent" +import { Skill } from "../../skill" +import { Global } from "../../global" +import { LSP } from "../../lsp" +import { Command } from "../../command" +import { QuestionRoutes } from "./question" +import { PermissionRoutes } from "./permission" +import { ProjectRoutes } from "./project" +import { SessionRoutes } from "./session" +import { PtyRoutes } from "./pty" +import { McpRoutes } from "./mcp" +import { FileRoutes } from "./file" +import { ConfigRoutes } from "./config" +import { ExperimentalRoutes } from "./experimental" +import { ProviderRoutes } from "./provider" +import { EventRoutes } from "./event" +import { WorkspaceRouterMiddleware } from "./middleware" +import { AppRuntime } from "@/effect/app-runtime" -const log = Log.create({ service: "server" }) - -const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI - ? Promise.resolve(null) - : // @ts-expect-error - generated file at build time - import("opencode-web-ui.gen.ts").then((module) => module.default as Record).catch(() => null) - -const DEFAULT_CSP = - "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:" - -const csp = (hash = "") => - `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:` - -export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()) => - app - .onError(errorHandler(log)) +export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => + new Hono() + .use(WorkspaceRouterMiddleware(upgrade)) .route("/project", ProjectRoutes()) .route("/pty", PtyRoutes(upgrade)) .route("/config", ConfigRoutes()) @@ -188,7 +171,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono() }, }), async (c) => { - const commands = await Command.list() + const commands = await AppRuntime.runPromise(Command.Service.use((svc) => svc.list())) return c.json(commands) }, ) @@ -275,41 +258,6 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono() }, }), async (c) => { - return c.json(await Format.status()) + return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status()))) }, ) - .all("/*", async (c) => { - const embeddedWebUI = await embeddedUIPromise - const path = c.req.path - - if (embeddedWebUI) { - const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null - if (!match) return c.json({ error: "Not Found" }, 404) - const file = Bun.file(match) - if (await file.exists()) { - c.header("Content-Type", file.type) - if (file.type.startsWith("text/html")) { - c.header("Content-Security-Policy", DEFAULT_CSP) - } - return c.body(await file.arrayBuffer()) - } 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( - /]*\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 - } - }) diff --git a/packages/opencode/src/server/routes/mcp.ts b/packages/opencode/src/server/instance/mcp.ts similarity index 100% rename from packages/opencode/src/server/routes/mcp.ts rename to packages/opencode/src/server/instance/mcp.ts diff --git a/packages/opencode/src/server/router.ts b/packages/opencode/src/server/instance/middleware.ts similarity index 56% rename from packages/opencode/src/server/router.ts rename to packages/opencode/src/server/instance/middleware.ts index b6f99ec73..1a5011477 100644 --- a/packages/opencode/src/server/router.ts +++ b/packages/opencode/src/server/instance/middleware.ts @@ -3,12 +3,13 @@ import type { UpgradeWebSocket } from "hono/ws" import { getAdaptor } from "@/control-plane/adaptors" import { WorkspaceID } from "@/control-plane/schema" import { Workspace } from "@/control-plane/workspace" -import { ServerProxy } from "./proxy" -import { lazy } from "@/util/lazy" +import { ServerProxy } from "../proxy" import { Filesystem } from "@/util/filesystem" import { Instance } from "@/project/instance" import { InstanceBootstrap } from "@/project/bootstrap" -import { InstanceRoutes } from "./instance" +import { Session } from "@/session" +import { SessionID } from "@/session/schema" +import { WorkspaceContext } from "@/control-plane/workspace-context" type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } @@ -26,10 +27,25 @@ function local(method: string, path: string) { return false } -export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler { - const routes = lazy(() => InstanceRoutes(upgrade)) +function getSessionID(url: URL) { + if (url.pathname === "/session/status") return null - return async (c) => { + const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] + if (!id) return null + + return SessionID.make(id) +} + +async function getSessionWorkspace(url: URL) { + const id = getSessionID(url) + if (!id) return null + + const session = await Session.get(id).catch(() => undefined) + return session?.workspaceID +} + +export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler { + return async (c, next) => { const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() const directory = Filesystem.resolve( (() => { @@ -42,25 +58,34 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware ) const url = new URL(c.req.url) - const workspaceParam = url.searchParams.get("workspace") || c.req.header("x-opencode-workspace") - // TODO: If session is being routed, force it to lookup the - // project/workspace + const sessionWorkspaceID = await getSessionWorkspace(url) + const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace") - // If no workspace is provided we use the "project" workspace - if (!workspaceParam) { + // If no workspace is provided we use the project + if (!workspaceID) { return Instance.provide({ directory, init: InstanceBootstrap, async fn() { - return routes().fetch(c.req.raw, c.env) + return next() }, }) } - const workspaceID = WorkspaceID.make(workspaceParam) - const workspace = await Workspace.get(workspaceID) + const workspace = await Workspace.get(WorkspaceID.make(workspaceID)) + if (!workspace) { + // Special-case deleting a session in case user's data in a + // weird state. Allow them to forcefully delete a synced session + // even if the remote workspace is not in their data. + // + // 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 new Response(`Workspace not found: ${workspaceID}`, { status: 500, headers: { @@ -73,23 +98,27 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware const target = await adaptor.target(workspace) if (target.type === "local") { - return Instance.provide({ - directory: target.directory, - init: InstanceBootstrap, - async fn() { - return routes().fetch(c.req.raw, c.env) - }, + return WorkspaceContext.provide({ + workspaceID: WorkspaceID.make(workspaceID), + fn: () => + Instance.provide({ + directory: target.directory, + init: InstanceBootstrap, + async fn() { + return next() + }, + }), }) } if (local(c.req.method, url.pathname)) { // No instance provided because we are serving cached data; there // is no instance to work with - return routes().fetch(c.req.raw, c.env) + return next() } if (c.req.header("upgrade")?.toLowerCase() === "websocket") { - return ServerProxy.websocket(target, c.req.raw, c.env) + return ServerProxy.websocket(upgrade, target, c.req.raw, c.env) } const headers = new Headers(c.req.raw.headers) diff --git a/packages/opencode/src/server/routes/permission.ts b/packages/opencode/src/server/instance/permission.ts similarity index 100% rename from packages/opencode/src/server/routes/permission.ts rename to packages/opencode/src/server/instance/permission.ts diff --git a/packages/opencode/src/server/routes/project.ts b/packages/opencode/src/server/instance/project.ts similarity index 100% rename from packages/opencode/src/server/routes/project.ts rename to packages/opencode/src/server/instance/project.ts diff --git a/packages/opencode/src/server/routes/provider.ts b/packages/opencode/src/server/instance/provider.ts similarity index 88% rename from packages/opencode/src/server/routes/provider.ts rename to packages/opencode/src/server/instance/provider.ts index 64fe34f45..efd126ea0 100644 --- a/packages/opencode/src/server/routes/provider.ts +++ b/packages/opencode/src/server/instance/provider.ts @@ -6,6 +6,7 @@ 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" @@ -28,7 +29,7 @@ export const ProviderRoutes = lazy(() => "application/json": { schema: resolver( z.object({ - all: ModelsDev.Provider.array(), + all: Provider.Info.array(), default: z.record(z.string(), z.string()), connected: z.array(z.string()), }), @@ -81,7 +82,7 @@ export const ProviderRoutes = lazy(() => }, }), async (c) => { - return c.json(await ProviderAuth.methods()) + return c.json(await AppRuntime.runPromise(ProviderAuth.Service.use((svc) => svc.methods()))) }, ) .post( @@ -118,11 +119,15 @@ export const ProviderRoutes = lazy(() => async (c) => { const providerID = c.req.valid("param").providerID const { method, inputs } = c.req.valid("json") - const result = await ProviderAuth.authorize({ - providerID, - method, - inputs, - }) + const result = await AppRuntime.runPromise( + ProviderAuth.Service.use((svc) => + svc.authorize({ + providerID, + method, + inputs, + }), + ), + ) return c.json(result) }, ) @@ -160,11 +165,15 @@ export const ProviderRoutes = lazy(() => async (c) => { const providerID = c.req.valid("param").providerID const { method, code } = c.req.valid("json") - await ProviderAuth.callback({ - providerID, - method, - code, - }) + await AppRuntime.runPromise( + ProviderAuth.Service.use((svc) => + svc.callback({ + providerID, + method, + code, + }), + ), + ) return c.json(true) }, ), diff --git a/packages/opencode/src/server/routes/pty.ts b/packages/opencode/src/server/instance/pty.ts similarity index 100% rename from packages/opencode/src/server/routes/pty.ts rename to packages/opencode/src/server/instance/pty.ts diff --git a/packages/opencode/src/server/routes/question.ts b/packages/opencode/src/server/instance/question.ts similarity index 84% rename from packages/opencode/src/server/routes/question.ts rename to packages/opencode/src/server/instance/question.ts index 3fff895fa..501ae2181 100644 --- a/packages/opencode/src/server/routes/question.ts +++ b/packages/opencode/src/server/instance/question.ts @@ -3,6 +3,7 @@ import { describeRoute, validator } from "hono-openapi" import { resolver } from "hono-openapi" import { QuestionID } from "@/question/schema" import { Question } from "../../question" +import { AppRuntime } from "@/effect/app-runtime" import z from "zod" import { errors } from "../error" import { lazy } from "../../util/lazy" @@ -27,7 +28,7 @@ export const QuestionRoutes = lazy(() => }, }), async (c) => { - const questions = await Question.list() + const questions = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list())) return c.json(questions) }, ) @@ -59,10 +60,14 @@ export const QuestionRoutes = lazy(() => async (c) => { const params = c.req.valid("param") const json = c.req.valid("json") - await Question.reply({ - requestID: params.requestID, - answers: json.answers, - }) + await AppRuntime.runPromise( + Question.Service.use((svc) => + svc.reply({ + requestID: params.requestID, + answers: json.answers, + }), + ), + ) return c.json(true) }, ) @@ -92,7 +97,7 @@ export const QuestionRoutes = lazy(() => ), async (c) => { const params = c.req.valid("param") - await Question.reject(params.requestID) + await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(params.requestID))) return c.json(true) }, ), diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/instance/session.ts similarity index 92% rename from packages/opencode/src/server/routes/session.ts rename to packages/opencode/src/server/instance/session.ts index c33c5e989..b28db3a89 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -6,13 +6,18 @@ import z from "zod" import { Session } from "../../session" import { MessageV2 } from "../../session/message-v2" import { SessionPrompt } from "../../session/prompt" +import { SessionRunState } from "@/session/run-state" import { SessionCompaction } from "../../session/compaction" import { SessionRevert } from "../../session/revert" +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" +import { Command } from "../../command" import { Log } from "../../util/log" import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" @@ -90,7 +95,7 @@ export const SessionRoutes = lazy(() => }, }), async (c) => { - const result = await SessionStatus.list() + const result = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list())) return c.json(Object.fromEntries(result)) }, ) @@ -121,7 +126,6 @@ export const SessionRoutes = lazy(() => ), async (c) => { const sessionID = c.req.valid("param").sessionID - log.info("SEARCH", { url: c.req.url }) const session = await Session.get(sessionID) return c.json(session) }, @@ -183,7 +187,7 @@ export const SessionRoutes = lazy(() => ), async (c) => { const sessionID = c.req.valid("param").sessionID - const todos = await Todo.get(sessionID) + const todos = await AppRuntime.runPromise(Todo.Service.use((svc) => svc.get(sessionID))) return c.json(todos) }, ) @@ -205,10 +209,10 @@ export const SessionRoutes = lazy(() => }, }, }), - validator("json", Session.create.schema.optional()), + validator("json", Session.create.schema), async (c) => { const body = c.req.valid("json") ?? {} - const session = await Session.create(body) + const session = await SessionShare.create(body) return c.json(session) }, ) @@ -270,6 +274,7 @@ export const SessionRoutes = lazy(() => "json", z.object({ title: z.string().optional(), + permission: Permission.Ruleset.optional(), time: z .object({ archived: z.number().optional(), @@ -280,10 +285,17 @@ export const SessionRoutes = lazy(() => async (c) => { const sessionID = c.req.valid("param").sessionID const updates = c.req.valid("json") + const current = await Session.get(sessionID) if (updates.title !== undefined) { await Session.setTitle({ sessionID, title: updates.title }) } + if (updates.permission !== undefined) { + await Session.setPermission({ + sessionID, + permission: Permission.merge(current.permission ?? [], updates.permission), + }) + } if (updates.time?.archived !== undefined) { await Session.setArchived({ sessionID, time: updates.time.archived }) } @@ -292,6 +304,7 @@ export const SessionRoutes = lazy(() => return c.json(session) }, ) + // TODO(v2): remove this dedicated route and rely on the normal `/init` command flow. .post( "/:sessionID/init", describeRoute({ @@ -317,11 +330,24 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - validator("json", Session.initialize.schema.omit({ sessionID: true })), + validator( + "json", + z.object({ + modelID: ModelID.zod, + providerID: ProviderID.zod, + messageID: MessageID.zod, + }), + ), async (c) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") - await Session.initialize({ ...body, sessionID }) + await SessionPrompt.command({ + sessionID, + messageID: body.messageID, + model: body.providerID + "/" + body.modelID, + command: Command.Default.INIT, + arguments: "", + }) return c.json(true) }, ) @@ -411,7 +437,7 @@ export const SessionRoutes = lazy(() => ), async (c) => { const sessionID = c.req.valid("param").sessionID - await Session.share(sessionID) + await SessionShare.share(sessionID) const session = await Session.get(sessionID) return c.json(session) }, @@ -476,12 +502,12 @@ export const SessionRoutes = lazy(() => validator( "param", z.object({ - sessionID: Session.unshare.schema, + sessionID: SessionID.zod, }), ), async (c) => { const sessionID = c.req.valid("param").sessionID - await Session.unshare(sessionID) + await SessionShare.unshare(sessionID) const session = await Session.get(sessionID) return c.json(session) }, @@ -699,11 +725,17 @@ export const SessionRoutes = lazy(() => ), async (c) => { const params = c.req.valid("param") - await SessionPrompt.assertNotBusy(params.sessionID) - await Session.removeMessage({ - sessionID: params.sessionID, - messageID: params.messageID, - }) + await AppRuntime.runPromise( + Effect.gen(function* () { + const state = yield* SessionRunState.Service + const session = yield* Session.Service + yield* state.assertNotBusy(params.sessionID) + yield* session.removeMessage({ + sessionID: params.sessionID, + messageID: params.messageID, + }) + }), + ) return c.json(true) }, ) @@ -843,19 +875,17 @@ export const SessionRoutes = lazy(() => ), validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })), async (c) => { - c.status(204) - c.header("Content-Type", "application/json") - return stream(c, async () => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - SessionPrompt.prompt({ ...body, sessionID }).catch((err) => { - log.error("prompt_async failed", { sessionID, error: err }) - Bus.publish(Session.Event.Error, { - sessionID, - error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(), - }) + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + SessionPrompt.prompt({ ...body, sessionID }).catch((err) => { + log.error("prompt_async failed", { sessionID, error: err }) + Bus.publish(Session.Event.Error, { + sessionID, + error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(), }) }) + + return c.body(null, 204) }, ) .post( @@ -906,7 +936,7 @@ export const SessionRoutes = lazy(() => description: "Created message", content: { "application/json": { - schema: resolver(MessageV2.Assistant), + schema: resolver(MessageV2.WithParts), }, }, }, diff --git a/packages/opencode/src/server/routes/tui.ts b/packages/opencode/src/server/instance/tui.ts similarity index 100% rename from packages/opencode/src/server/routes/tui.ts rename to packages/opencode/src/server/instance/tui.ts diff --git a/packages/opencode/src/server/routes/workspace.ts b/packages/opencode/src/server/instance/workspace.ts similarity index 77% rename from packages/opencode/src/server/routes/workspace.ts rename to packages/opencode/src/server/instance/workspace.ts index cd2d844ae..419321654 100644 --- a/packages/opencode/src/server/routes/workspace.ts +++ b/packages/opencode/src/server/instance/workspace.ts @@ -62,6 +62,28 @@ export const WorkspaceRoutes = lazy(() => return c.json(Workspace.list(Instance.project)) }, ) + .get( + "/status", + describeRoute({ + summary: "Workspace status", + description: "Get connection status for workspaces in the current project.", + operationId: "experimental.workspace.status", + responses: { + 200: { + description: "Workspace status", + content: { + "application/json": { + schema: resolver(z.array(Workspace.ConnectionStatus)), + }, + }, + }, + }, + }), + async (c) => { + const ids = new Set(Workspace.list(Instance.project).map((item) => item.id)) + return c.json(Workspace.status().filter((item) => ids.has(item.workspaceID))) + }, + ) .delete( "/:id", describeRoute({ diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index 278740c57..a51ba602b 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -3,31 +3,90 @@ import { NamedError } from "@opencode-ai/util/error" import { NotFoundError } from "../storage/db" import { Session } from "../session" import type { ContentfulStatusCode } from "hono/utils/http-status" -import type { ErrorHandler } from "hono" +import type { ErrorHandler, MiddlewareHandler } from "hono" import { HTTPException } from "hono/http-exception" -import type { Log } from "../util/log" +import { Log } from "../util/log" +import { Flag } from "@/flag/flag" +import { basicAuth } from "hono/basic-auth" +import { cors } from "hono/cors" +import { compress } from "hono/compress" -export function errorHandler(log: Log.Logger): ErrorHandler { - return (err, c) => { - log.error("failed", { - error: err, - }) - if (err instanceof NamedError) { - let status: ContentfulStatusCode - if (err instanceof NotFoundError) status = 404 - else if (err instanceof Provider.ModelNotFoundError) status = 400 - else if (err.name === "ProviderAuthValidationFailed") status = 400 - else if (err.name.startsWith("Worktree")) status = 400 - else status = 500 - return c.json(err.toObject(), { status }) - } - if (err instanceof Session.BusyError) { - return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 }) - } - if (err instanceof HTTPException) return err.getResponse() - const message = err instanceof Error && err.stack ? err.stack : err.toString() - return c.json(new NamedError.Unknown({ message }).toObject(), { - status: 500, +const log = Log.create({ service: "server" }) + +export const ErrorMiddleware: ErrorHandler = (err, c) => { + log.error("failed", { + error: err, + }) + if (err instanceof NamedError) { + let status: ContentfulStatusCode + if (err instanceof NotFoundError) status = 404 + else if (err instanceof Provider.ModelNotFoundError) status = 400 + else if (err.name === "ProviderAuthValidationFailed") status = 400 + else if (err.name.startsWith("Worktree")) status = 400 + else status = 500 + return c.json(err.toObject(), { status }) + } + if (err instanceof Session.BusyError) { + return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 }) + } + if (err instanceof HTTPException) return err.getResponse() + const message = err instanceof Error && err.stack ? err.stack : err.toString() + return c.json(new NamedError.Unknown({ message }).toObject(), { + status: 500, + }) +} + +export const AuthMiddleware: MiddlewareHandler = (c, next) => { + // Allow CORS preflight requests to succeed without auth. + // Browser clients sending Authorization headers will preflight with OPTIONS. + if (c.req.method === "OPTIONS") return next() + const password = Flag.OPENCODE_SERVER_PASSWORD + if (!password) return next() + const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" + + if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`) + + return basicAuth({ username, password })(c, next) +} + +export const LoggerMiddleware: MiddlewareHandler = async (c, next) => { + const skip = c.req.path === "/log" + if (!skip) { + log.info("request", { + method: c.req.method, + path: c.req.path, }) } + const timer = log.time("request", { + method: c.req.method, + path: c.req.path, + }) + await next() + if (!skip) timer.stop() +} + +export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler { + return cors({ + maxAge: 86_400, + origin(input) { + if (!input) return + + if (input.startsWith("http://localhost:")) return input + if (input.startsWith("http://127.0.0.1:")) return input + if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost") + return input + + if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input + if (opts?.cors?.includes(input)) return input + }, + }) +} + +const zipped = compress() +export const CompressionMiddleware: MiddlewareHandler = (c, next) => { + const path = c.req.path + const method = c.req.method + if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return next() + if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return next() + return zipped(c, next) } diff --git a/packages/opencode/src/server/proxy.ts b/packages/opencode/src/server/proxy.ts index c489c6b42..c90a657dc 100644 --- a/packages/opencode/src/server/proxy.ts +++ b/packages/opencode/src/server/proxy.ts @@ -1,7 +1,6 @@ import type { Target } from "@/control-plane/types" -import { lazy } from "@/util/lazy" import { Hono } from "hono" -import { upgradeWebSocket } from "hono/bun" +import type { UpgradeWebSocket } from "hono/ws" const hop = new Set([ "connection", @@ -53,10 +52,10 @@ function send(ws: { send(data: string | ArrayBuffer | Uint8Array): void }, data: return ws.send(data) } -const app = lazy(() => +const app = (upgrade: UpgradeWebSocket) => new Hono().get( "/__workspace_ws", - upgradeWebSocket((c) => { + upgrade((c) => { const url = c.req.header("x-opencode-proxy-url") const queue: Msg[] = [] let remote: WebSocket | undefined @@ -96,8 +95,7 @@ const app = lazy(() => }, } }), - ), -) + ) export namespace ServerProxy { export function http(target: Extract, req: Request) { @@ -112,13 +110,18 @@ export namespace ServerProxy { ) } - export function websocket(target: Extract, req: Request, env: unknown) { + export function websocket( + upgrade: UpgradeWebSocket, + target: Extract, + req: Request, + env: unknown, + ) { const url = new URL(req.url) url.pathname = "/__workspace_ws" url.search = "" const next = new Headers(req.headers) next.set("x-opencode-proxy-url", socket(target.url)) - return app().fetch( + return app(upgrade).fetch( new Request(url, { method: req.method, headers: next, diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 3822da71e..02ec7356e 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1,24 +1,14 @@ -import { Log } from "../util/log" -import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi" +import { generateSpecs } from "hono-openapi" import { Hono } from "hono" -import { compress } from "hono/compress" -import { 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 { createAdaptorServer, type ServerType } from "@hono/node-server" -import { createNodeWebSocket } from "@hono/node-ws" -import { WorkspaceRouterMiddleware } from "./router" -import { errors } from "./error" -import { GlobalRoutes } from "./routes/global" +import { adapter } from "#hono" import { MDNS } from "./mdns" import { lazy } from "@/util/lazy" -import { errorHandler } from "./middleware" +import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware" import { InstanceRoutes } from "./instance" import { initProjectors } from "./projectors" +import { Log } from "@/util/log" +import { ControlPlaneRoutes } from "./control" +import { UIRoutes } from "./ui" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -26,6 +16,8 @@ globalThis.AI_SDK_LOG_WARNINGS = false initProjectors() export namespace Server { + const log = Log.create({ service: "server" }) + export type Listener = { hostname: string port: number @@ -33,228 +25,31 @@ export namespace Server { stop: (close?: boolean) => Promise } - 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({}).app) - - 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" - 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)) - } + export const Default = lazy(() => create({})) function create(opts: { cors?: string[] }) { const app = new Hono() - const ws = createNodeWebSocket({ app }) + const runtime = adapter.create(app) return { - app: ControlPlaneRoutes(ws.upgradeWebSocket, app, opts), - ws, + app: app + .onError(ErrorMiddleware) + .use(AuthMiddleware) + .use(LoggerMiddleware) + .use(CompressionMiddleware) + .use(CorsMiddleware(opts)) + .route("/", ControlPlaneRoutes()) + .route("/", InstanceRoutes(runtime.upgradeWebSocket)) + .route("/", UIRoutes()), + runtime, } } - export function createApp(opts: { cors?: string[] }) { - return create(opts).app - } - export async function openapi() { // Build a fresh app with all routes registered directly so // hono-openapi can see describeRoute metadata (`.route()` wraps // handlers when the sub-app has a custom errorHandler, which // strips the metadata symbol). - const { app, ws } = create({}) - InstanceRoutes(ws.upgradeWebSocket, app) + const { app } = create({}) const result = await generateSpecs(app, { documentation: { info: { @@ -278,46 +73,21 @@ export namespace Server { cors?: string[] }): Promise { const built = create(opts) - const start = (port: number) => - new Promise((resolve, reject) => { - const server = createAdaptorServer({ fetch: built.app.fetch }) - built.ws.injectWebSocket(server) - const fail = (err: Error) => { - cleanup() - reject(err) - } - const ready = () => { - cleanup() - resolve(server) - } - const cleanup = () => { - server.off("error", fail) - server.off("listening", ready) - } - server.once("error", fail) - server.once("listening", ready) - server.listen(port, opts.hostname) - }) - - const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port) - const addr = server.address() - if (!addr || typeof addr === "string") { - throw new Error(`Failed to resolve server address for port ${opts.port}`) - } + const server = await built.runtime.listen(opts) const next = new URL("http://localhost") next.hostname = opts.hostname - next.port = String(addr.port) + next.port = String(server.port) url = next const mdns = opts.mdns && - addr.port && + server.port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1" if (mdns) { - MDNS.publish(addr.port, opts.mdnsDomain) + MDNS.publish(server.port, opts.mdnsDomain) } else if (opts.mdns) { log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") } @@ -325,27 +95,13 @@ export namespace Server { let closing: Promise | undefined return { hostname: opts.hostname, - port: addr.port, + port: server.port, url: next, stop(close?: boolean) { - closing ??= new Promise((resolve, reject) => { + closing ??= (async () => { if (mdns) MDNS.unpublish() - server.close((err) => { - if (err) { - reject(err) - return - } - resolve() - }) - if (close) { - if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") { - server.closeAllConnections() - } - if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") { - server.closeIdleConnections() - } - } - }) + await server.stop(close) + })() return closing }, } diff --git a/packages/opencode/src/server/ui/index.ts b/packages/opencode/src/server/ui/index.ts new file mode 100644 index 000000000..afe6e510f --- /dev/null +++ b/packages/opencode/src/server/ui/index.ts @@ -0,0 +1,55 @@ +import { Flag } from "@/flag/flag" +import { Hono } from "hono" +import { proxy } from "hono/proxy" +import { getMimeType } from "hono/utils/mime" +import { createHash } from "node:crypto" +import fs from "node:fs/promises" + +const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI + ? Promise.resolve(null) + : // @ts-expect-error - generated file at build time + import("opencode-web-ui.gen.ts").then((module) => module.default as Record).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( + /]*\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 + } + }) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index bbdce9fd7..b280971c7 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -15,7 +15,7 @@ import { Plugin } from "@/plugin" import { Config } from "@/config/config" import { NotFoundError } from "@/storage/db" import { ModelID, ProviderID } from "@/provider/schema" -import { Effect, Layer, ServiceMap } from "effect" +import { Effect, Layer, Context } from "effect" import { makeRuntime } from "@/effect/run-service" import { InstanceState } from "@/effect/instance-state" import { isOverflow as overflow } from "./overflow" @@ -58,7 +58,7 @@ export namespace SessionCompaction { }) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/SessionCompaction") {} + export class Service extends Context.Service()("@opencode/SessionCompaction") {} export const layer: Layer.Layer< Service, @@ -253,23 +253,21 @@ When constructing the summary, try to stick to this template: sessionID: input.sessionID, model, }) - const result = yield* processor - .process({ - user: userMessage, - agent, - sessionID: input.sessionID, - tools: {}, - system: [], - messages: [ - ...modelMessages, - { - role: "user", - content: [{ type: "text", text: prompt }], - }, - ], - model, - }) - .pipe(Effect.onInterrupt(() => processor.abort())) + const result = yield* processor.process({ + user: userMessage, + agent, + sessionID: input.sessionID, + tools: {}, + system: [], + messages: [ + ...modelMessages, + { + role: "user", + content: [{ type: "text", text: prompt }], + }, + ], + model, + }) if (result === "compact") { processor.message.error = new MessageV2.ContextOverflowError({ @@ -379,17 +377,15 @@ When constructing the summary, try to stick to this template: }), ) - export const defaultLayer = Layer.unwrap( - Effect.sync(() => - layer.pipe( - Layer.provide(Provider.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(SessionProcessor.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(Bus.layer), - Layer.provide(Config.defaultLayer), - ), + export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(Provider.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(SessionProcessor.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(Config.defaultLayer), ), ) @@ -403,17 +399,6 @@ When constructing the summary, try to stick to this template: return runPromise((svc) => svc.prune(input)) } - export const process = fn( - z.object({ - parentID: MessageID.zod, - messages: z.custom(), - sessionID: SessionID.zod, - auto: z.boolean(), - overflow: z.boolean().optional(), - }), - (input) => runPromise((svc) => svc.process(input)), - ) - export const create = fn( z.object({ sessionID: SessionID.zod, diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 65032de96..3d4903588 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -5,14 +5,13 @@ import { Bus } from "@/bus" import { Decimal } from "decimal.js" import z from "zod" import { type ProviderMetadata } from "ai" -import { Config } from "../config/config" import { Flag } from "../flag/flag" import { Installation } from "../installation" import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db" import { SyncEvent } from "../sync" import type { SQL } from "../storage/db" -import { SessionTable } from "./session.sql" +import { PartTable, SessionTable } from "./session.sql" import { ProjectTable } from "../project/project.sql" import { Storage } from "@/storage/storage" import { Log } from "../util/log" @@ -20,20 +19,17 @@ import { updateSchema } from "../util/update-schema" import { MessageV2 } from "./message-v2" import { Instance } from "../project/instance" import { InstanceState } from "@/effect/instance-state" -import { SessionPrompt } from "./prompt" import { fn } from "@/util/fn" -import { Command } from "../command" import { Snapshot } from "@/snapshot" import { ProjectID } from "../project/schema" import { WorkspaceID } from "../control-plane/schema" import { SessionID, MessageID, PartID } from "./schema" import type { Provider } from "@/provider/provider" -import { ModelID, ProviderID } from "@/provider/schema" import { Permission } from "@/permission" import { Global } from "@/global" import type { LanguageModelV2Usage } from "@ai-sdk/provider" -import { Effect, Layer, Scope, ServiceMap } from "effect" +import { Effect, Layer, Option, Context } from "effect" import { makeRuntime } from "@/effect/run-service" export namespace Session { @@ -322,8 +318,6 @@ export namespace Session { readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect readonly touch: (sessionID: SessionID) => Effect.Effect readonly get: (id: SessionID) => Effect.Effect - readonly share: (id: SessionID) => Effect.Effect<{ url: string }> - readonly unshare: (id: SessionID) => Effect.Effect readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect @@ -345,6 +339,11 @@ export namespace Session { messageID: MessageID partID: PartID }) => Effect.Effect + readonly getPart: (input: { + sessionID: SessionID + messageID: MessageID + partID: PartID + }) => Effect.Effect readonly updatePart: (part: T) => Effect.Effect readonly updatePartDelta: (input: { sessionID: SessionID @@ -353,27 +352,25 @@ export namespace Session { field: string delta: string }) => Effect.Effect - readonly initialize: (input: { - sessionID: SessionID - modelID: ModelID - providerID: ProviderID - messageID: MessageID - }) => Effect.Effect + /** Finds the first message matching the predicate, searching newest-first. */ + readonly findMessage: ( + sessionID: SessionID, + predicate: (msg: MessageV2.WithParts) => boolean, + ) => Effect.Effect> } - export class Service extends ServiceMap.Service()("@opencode/Session") {} + export class Service extends Context.Service()("@opencode/Session") {} type Patch = z.infer["info"] const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => Effect.sync(() => Database.use(fn)) - export const layer: Layer.Layer = Layer.effect( + export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service - const config = yield* Config.Service - const scope = yield* Scope.Scope + const storage = yield* Storage.Service const createNext = Effect.fn("Session.createNext")(function* (input: { id?: SessionID @@ -403,11 +400,6 @@ export namespace Session { yield* Effect.sync(() => SyncEvent.run(Event.Created, { sessionID: result.id, info: result })) - const cfg = yield* config.get() - if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) { - yield* share(result.id).pipe(Effect.ignore, Effect.forkIn(scope)) - } - if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { // This only exist for backwards compatibility. We should not be // manually publishing this event; it is a sync event now @@ -426,47 +418,36 @@ export namespace Session { return fromRow(row) }) - const share = Effect.fn("Session.share")(function* (id: SessionID) { - const cfg = yield* config.get() - if (cfg.share === "disabled") throw new Error("Sharing is disabled in configuration") - const result = yield* Effect.promise(async () => { - const { ShareNext } = await import("@/share/share-next") - return ShareNext.create(id) - }) - yield* Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: result.url } } })) - return result - }) - - const unshare = Effect.fn("Session.unshare")(function* (id: SessionID) { - yield* Effect.promise(async () => { - const { ShareNext } = await import("@/share/share-next") - await ShareNext.remove(id) - }) - yield* Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: null } } })) - }) - const children = Effect.fn("Session.children")(function* (parentID: SessionID) { - const ctx = yield* InstanceState.context const rows = yield* db((d) => d .select() .from(SessionTable) - .where(and(eq(SessionTable.project_id, ctx.project.id), eq(SessionTable.parent_id, parentID))) + .where(and(eq(SessionTable.parent_id, parentID))) .all(), ) return rows.map(fromRow) }) - const remove: (sessionID: SessionID) => Effect.Effect = Effect.fnUntraced(function* (sessionID: SessionID) { + const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) { try { const session = yield* get(sessionID) const kids = yield* children(sessionID) for (const child of kids) { yield* remove(child.id) } - yield* unshare(sessionID).pipe(Effect.ignore) + + // `remove` needs to work in all cases, such as a broken + // sessions that run cleanup. In certain cases these will + // run without any instance state, so we need to turn off + // publishing of events in that case + const hasInstance = yield* InstanceState.directory.pipe( + Effect.as(true), + Effect.catchCause(() => Effect.succeed(false)), + ) + yield* Effect.sync(() => { - SyncEvent.run(Event.Deleted, { sessionID, info: session }) + SyncEvent.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance }) SyncEvent.remove(sessionID) }) } catch (e) { @@ -492,6 +473,29 @@ export namespace Session { return part }).pipe(Effect.withSpan("Session.updatePart")) + const getPart: Interface["getPart"] = Effect.fn("Session.getPart")(function* (input) { + const row = Database.use((db) => + db + .select() + .from(PartTable) + .where( + and( + eq(PartTable.session_id, input.sessionID), + eq(PartTable.message_id, input.messageID), + eq(PartTable.id, input.partID), + ), + ) + .get(), + ) + if (!row) return + return { + ...row.data, + id: row.id, + sessionID: row.session_id, + messageID: row.message_id, + } as MessageV2.Part + }) + const create = Effect.fn("Session.create")(function* (input?: { parentID?: SessionID title?: string @@ -587,9 +591,9 @@ export namespace Session { }) const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) { - return yield* Effect.tryPromise(() => Storage.read(["session_diff", sessionID])).pipe( - Effect.orElseSucceed(() => [] as Snapshot.FileDiff[]), - ) + return yield* storage + .read(["session_diff", sessionID]) + .pipe(Effect.orElseSucceed((): Snapshot.FileDiff[] => [])) }) const messages = Effect.fn("Session.messages")(function* (input: { sessionID: SessionID; limit?: number }) { @@ -637,21 +641,15 @@ export namespace Session { yield* bus.publish(MessageV2.Event.PartDelta, input) }) - const initialize = Effect.fn("Session.initialize")(function* (input: { - sessionID: SessionID - modelID: ModelID - providerID: ProviderID - messageID: MessageID - }) { - yield* Effect.promise(() => - SessionPrompt.command({ - sessionID: input.sessionID, - messageID: input.messageID, - model: input.providerID + "/" + input.modelID, - command: Command.Default.INIT, - arguments: "", - }), - ) + /** Finds the first message matching the predicate, searching newest-first. */ + const findMessage = Effect.fn("Session.findMessage")(function* ( + sessionID: SessionID, + predicate: (msg: MessageV2.WithParts) => boolean, + ) { + for (const item of MessageV2.stream(sessionID)) { + if (predicate(item)) return Option.some(item) + } + return Option.none() }) return Service.of({ @@ -659,8 +657,6 @@ export namespace Session { fork, touch, get, - share, - unshare, setTitle, setArchived, setPermission, @@ -675,13 +671,14 @@ export namespace Session { removeMessage, removePart, updatePart, + getPart, updatePartDelta, - initialize, + findMessage, }) }), ) - export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer)) + export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer)) const { runPromise } = makeRuntime(Service, defaultLayer) @@ -701,10 +698,7 @@ export namespace Session { runPromise((svc) => svc.fork(input)), ) - export const touch = fn(SessionID.zod, (id) => runPromise((svc) => svc.touch(id))) export const get = fn(SessionID.zod, (id) => runPromise((svc) => svc.get(id))) - export const share = fn(SessionID.zod, (id) => runPromise((svc) => svc.share(id))) - export const unshare = fn(SessionID.zod, (id) => runPromise((svc) => svc.unshare(id))) export const setTitle = fn(z.object({ sessionID: SessionID.zod, title: z.string() }), (input) => runPromise((svc) => svc.setTitle(input)), @@ -724,14 +718,6 @@ export namespace Session { runPromise((svc) => svc.setRevert({ sessionID: input.sessionID, revert: input.revert, summary: input.summary })), ) - export const clearRevert = fn(SessionID.zod, (id) => runPromise((svc) => svc.clearRevert(id))) - - export const setSummary = fn(z.object({ sessionID: SessionID.zod, summary: Info.shape.summary }), (input) => - runPromise((svc) => svc.setSummary({ sessionID: input.sessionID, summary: input.summary })), - ) - - export const diff = fn(SessionID.zod, (id) => runPromise((svc) => svc.diff(id))) - export const messages = fn(z.object({ sessionID: SessionID.zod, limit: z.number().optional() }), (input) => runPromise((svc) => svc.messages(input)), ) @@ -868,20 +854,4 @@ 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)), - ) - - export const initialize = fn( - z.object({ sessionID: SessionID.zod, modelID: ModelID.zod, providerID: ProviderID.zod, messageID: MessageID.zod }), - (input) => runPromise((svc) => svc.initialize(input)), - ) } diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index fc90093e9..04f2610df 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -1,10 +1,9 @@ import os from "os" import path from "path" -import { Effect, Layer, ServiceMap } from "effect" +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" @@ -64,7 +63,7 @@ export namespace Instruction { ) => Effect.Effect<{ filepath: string; content: string }[], AppFileSystem.Error> } - export class Service extends ServiceMap.Service()("@opencode/Instruction") {} + export class Service extends Context.Service()("@opencode/Instruction") {} export const layer: Layer.Layer = Layer.effect( @@ -238,21 +237,7 @@ export namespace Instruction { Layer.provide(FetchHttpClient.layer), ) - const { runPromise } = makeRuntime(Service, defaultLayer) - - export function clear(messageID: MessageID) { - return runPromise((svc) => svc.clear(messageID)) - } - - export async function systemPaths() { - return runPromise((svc) => svc.systemPaths()) - } - export function loaded(messages: MessageV2.WithParts[]) { return extract(messages) } - - export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: MessageID) { - return runPromise((svc) => svc.resolve(messages, filepath, messageID)) - } } diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index c9a62c864..f6e5c9a3f 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,6 +1,6 @@ import { Provider } from "@/provider/provider" import { Log } from "@/util/log" -import { Cause, Effect, Layer, Record, ServiceMap } from "effect" +import { Cause, Effect, Layer, Record, Context } from "effect" import * as Queue from "effect/Queue" import * as Stream from "effect/Stream" import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai" @@ -15,6 +15,10 @@ import { Plugin } from "@/plugin" import { SystemPrompt } from "./system" import { Flag } from "@/flag/flag" import { Permission } from "@/permission" +import { PermissionID } from "@/permission/schema" +import { Bus } from "@/bus" +import { Wildcard } from "@/util/wildcard" +import { SessionID } from "@/session/schema" import { Auth } from "@/auth" import { Installation } from "@/installation" @@ -47,7 +51,7 @@ export namespace LLM { readonly stream: (input: StreamInput) => Stream.Stream } - export class Service extends ServiceMap.Service()("@opencode/LLM") {} + export class Service extends Context.Service()("@opencode/LLM") {} export const layer = Layer.effect( Service, @@ -230,7 +234,12 @@ export namespace LLM { // from the workflow service are executed via opencode's tool system // and results sent back over the WebSocket. if (language instanceof GitLabWorkflowLanguageModel) { - const workflowModel = language + const workflowModel = language as GitLabWorkflowLanguageModel & { + sessionID?: string + sessionPreapprovedTools?: string[] + approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }> + } + workflowModel.sessionID = input.sessionID workflowModel.systemPrompt = system.join("\n") workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => { const t = tools[toolName] @@ -253,6 +262,57 @@ export namespace LLM { return { result: "", error: e.message ?? String(e) } } } + + const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? []) + workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => { + const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission)) + return !match || match.action !== "ask" + }) + + const approvedToolsForSession = new Set() + workflowModel.approvalHandler = Instance.bind(async (approvalTools) => { + const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[] + // Auto-approve tools that were already approved in this session + // (prevents infinite approval loops for server-side MCP tools) + if (uniqueNames.every((name) => approvedToolsForSession.has(name))) { + return { approved: true } + } + + const id = PermissionID.ascending() + let reply: Permission.Reply | undefined + let unsub: (() => void) | undefined + try { + unsub = Bus.subscribe(Permission.Event.Replied, (evt) => { + if (evt.properties.requestID === id) reply = evt.properties.reply + }) + const toolPatterns = approvalTools.map((t: { name: string; args: string }) => { + try { + const parsed = JSON.parse(t.args) as Record + const title = (parsed?.title ?? parsed?.name ?? "") as string + return title ? `${t.name}: ${title}` : t.name + } catch { + return t.name + } + }) + const uniquePatterns = [...new Set(toolPatterns)] as string[] + await Permission.ask({ + id, + sessionID: SessionID.make(input.sessionID), + permission: "workflow_tool_approval", + patterns: uniquePatterns, + metadata: { tools: approvalTools }, + always: uniquePatterns, + ruleset: [], + }) + for (const name of uniqueNames) approvedToolsForSession.add(name) + workflowModel.sessionPreapprovedTools = [...(workflowModel.sessionPreapprovedTools ?? []), ...uniqueNames] + return { approved: true } + } catch { + return { approved: false } + } finally { + unsub?.() + } + }) } return streamText({ diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index e8aab62d8..4c18d1f7e 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -15,6 +15,7 @@ import type { SystemError } from "bun" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Effect } from "effect" +import { EffectLogger } from "@/effect/logger" /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ interface FetchDecompressionError extends Error { @@ -24,6 +25,8 @@ interface FetchDecompressionError extends Error { } export namespace MessageV2 { + export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:" + export function isMedia(mime: string) { return mime.startsWith("image/") || mime === "application/pdf" } @@ -573,6 +576,12 @@ export namespace MessageV2 { })) } + function providerMeta(metadata: Record | undefined) { + if (!metadata) return undefined + const { providerExecuted: _, ...rest } = metadata + return Object.keys(rest).length > 0 ? rest : undefined + } + export const toModelMessagesEffect = Effect.fnUntraced(function* ( input: WithParts[], model: Provider.Model, @@ -741,18 +750,34 @@ export namespace MessageV2 { toolCallId: part.callID, input: part.state.input, output, - ...(differentModel ? {} : { callProviderMetadata: part.metadata }), + ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), + ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), }) } - if (part.state.status === "error") - assistantMessage.parts.push({ - type: ("tool-" + part.tool) as `tool-${string}`, - state: "output-error", - toolCallId: part.callID, - input: part.state.input, - errorText: part.state.error, - ...(differentModel ? {} : { callProviderMetadata: part.metadata }), - }) + if (part.state.status === "error") { + const output = part.state.metadata?.interrupted === true ? part.state.metadata.output : undefined + if (typeof output === "string") { + assistantMessage.parts.push({ + type: ("tool-" + part.tool) as `tool-${string}`, + state: "output-available", + toolCallId: part.callID, + input: part.state.input, + output, + ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), + ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), + }) + } else { + assistantMessage.parts.push({ + type: ("tool-" + part.tool) as `tool-${string}`, + state: "output-error", + toolCallId: part.callID, + input: part.state.input, + errorText: part.state.error, + ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), + ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), + }) + } + } // Handle pending/running tool calls to prevent dangling tool_use blocks // Anthropic/Claude APIs require every tool_use to have a corresponding tool_result if (part.state.status === "pending" || part.state.status === "running") @@ -762,7 +787,8 @@ export namespace MessageV2 { toolCallId: part.callID, input: part.state.input, errorText: "[Tool execution was interrupted]", - ...(differentModel ? {} : { callProviderMetadata: part.metadata }), + ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), + ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), }) } if (part.type === "reasoning") { @@ -784,7 +810,7 @@ export namespace MessageV2 { parts: [ { type: "text" as const, - text: "Attached image(s) from tool result:", + text: SYNTHETIC_ATTACHMENT_PROMPT, }, ...media.map((attachment) => ({ type: "file" as const, @@ -816,7 +842,7 @@ export namespace MessageV2 { model: Provider.Model, options?: { stripMedia?: boolean }, ): Promise { - return Effect.runPromise(toModelMessagesEffect(input, model, options)) + return Effect.runPromise(toModelMessagesEffect(input, model, options).pipe(Effect.provide(EffectLogger.layer))) } export function page(input: { sessionID: SessionID; limit: number; before?: string }) { diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 9adc63736..df7ba40bf 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Layer, ServiceMap } from "effect" +import { Cause, Deferred, Effect, Layer, Context } from "effect" import * as Stream from "effect/Stream" import { Agent } from "@/agent/agent" import { Bus } from "@/bus" @@ -6,7 +6,7 @@ import { Config } from "@/config/config" import { Permission } from "@/permission" import { Plugin } from "@/plugin" import { Snapshot } from "@/snapshot" -import { Log } from "@/util/log" +import { EffectLogger } from "@/effect/logger" import { Session } from "." import { LLM } from "./llm" import { MessageV2 } from "./message-v2" @@ -18,10 +18,12 @@ import { SessionStatus } from "./status" import { SessionSummary } from "./summary" import type { Provider } from "@/provider/provider" import { Question } from "@/question" +import { errorMessage } from "@/util/error" +import { isRecord } from "@/util/record" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 - const log = Log.create({ service: "session.processor" }) + const log = EffectLogger.create({ service: "session.processor" }) export type Result = "compact" | "stop" | "continue" @@ -29,8 +31,19 @@ export namespace SessionProcessor { export interface Handle { readonly message: MessageV2.Assistant - readonly partFromToolCall: (toolCallID: string) => MessageV2.ToolPart | undefined - readonly abort: () => Effect.Effect + readonly updateToolCall: ( + toolCallID: string, + update: (part: MessageV2.ToolPart) => MessageV2.ToolPart, + ) => Effect.Effect + readonly completeToolCall: ( + toolCallID: string, + output: { + title: string + metadata: Record + output: string + attachments?: MessageV2.FilePart[] + }, + ) => Effect.Effect readonly process: (streamInput: LLM.StreamInput) => Effect.Effect } @@ -44,8 +57,15 @@ export namespace SessionProcessor { readonly create: (input: Input) => Effect.Effect } + type ToolCall = { + partID: MessageV2.ToolPart["id"] + messageID: MessageV2.ToolPart["messageID"] + sessionID: MessageV2.ToolPart["sessionID"] + done: Deferred.Deferred + } + interface ProcessorContext extends Input { - toolcalls: Record + toolcalls: Record shouldBreak: boolean snapshot: string | undefined blocked: boolean @@ -56,7 +76,7 @@ export namespace SessionProcessor { type StreamEvent = Event - export class Service extends ServiceMap.Service()("@opencode/SessionProcessor") {} + export class Service extends Context.Service()("@opencode/SessionProcessor") {} export const layer: Layer.Layer< Service, @@ -101,6 +121,7 @@ export namespace SessionProcessor { reasoningMap: {}, } let aborted = false + const slog = log.with({ sessionID: input.sessionID, messageID: input.assistantMessage.id }) const parse = (e: unknown) => MessageV2.fromError(e, { @@ -108,6 +129,88 @@ export namespace SessionProcessor { aborted, }) + const settleToolCall = Effect.fn("SessionProcessor.settleToolCall")(function* (toolCallID: string) { + const done = ctx.toolcalls[toolCallID]?.done + delete ctx.toolcalls[toolCallID] + if (done) yield* Deferred.succeed(done, undefined).pipe(Effect.ignore) + }) + + const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) { + const call = ctx.toolcalls[toolCallID] + if (!call) return + const part = yield* session.getPart({ + partID: call.partID, + messageID: call.messageID, + sessionID: call.sessionID, + }) + if (!part || part.type !== "tool") { + delete ctx.toolcalls[toolCallID] + return + } + return { call, part } + }) + + const updateToolCall = Effect.fn("SessionProcessor.updateToolCall")(function* ( + toolCallID: string, + update: (part: MessageV2.ToolPart) => MessageV2.ToolPart, + ) { + const match = yield* readToolCall(toolCallID) + if (!match) return + const part = yield* session.updatePart(update(match.part)) + ctx.toolcalls[toolCallID] = { + ...match.call, + partID: part.id, + messageID: part.messageID, + sessionID: part.sessionID, + } + return part + }) + + const completeToolCall = Effect.fn("SessionProcessor.completeToolCall")(function* ( + toolCallID: string, + output: { + title: string + metadata: Record + output: string + attachments?: MessageV2.FilePart[] + }, + ) { + const match = yield* readToolCall(toolCallID) + if (!match || match.part.state.status !== "running") return + yield* session.updatePart({ + ...match.part, + state: { + status: "completed", + input: match.part.state.input, + output: output.output, + metadata: output.metadata, + title: output.title, + time: { start: match.part.state.time.start, end: Date.now() }, + attachments: output.attachments, + }, + }) + yield* settleToolCall(toolCallID) + }) + + const failToolCall = Effect.fn("SessionProcessor.failToolCall")(function* (toolCallID: string, error: unknown) { + const match = yield* readToolCall(toolCallID) + if (!match || match.part.state.status !== "running") return false + yield* session.updatePart({ + ...match.part, + state: { + status: "error", + input: match.part.state.input, + error: errorMessage(error), + time: { start: match.part.state.time.start, end: Date.now() }, + }, + }) + if (error instanceof Permission.RejectedError || error instanceof Question.RejectedError) { + ctx.blocked = ctx.shouldBreak + } + yield* settleToolCall(toolCallID) + return true + }) + const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) { switch (value.type) { case "start": @@ -143,7 +246,7 @@ export namespace SessionProcessor { case "reasoning-end": if (!(value.id in ctx.reasoningMap)) return - ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text.trimEnd() + ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() } if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata yield* session.updatePart(ctx.reasoningMap[value.id]) @@ -154,15 +257,22 @@ export namespace SessionProcessor { if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } - ctx.toolcalls[value.id] = yield* session.updatePart({ - id: ctx.toolcalls[value.id]?.id ?? PartID.ascending(), + const part = yield* session.updatePart({ + id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(), messageID: ctx.assistantMessage.id, sessionID: ctx.assistantMessage.sessionID, type: "tool", tool: value.toolName, callID: value.id, state: { status: "pending", input: {}, raw: "" }, + metadata: value.providerExecuted ? { providerExecuted: true } : undefined, } satisfies MessageV2.ToolPart) + ctx.toolcalls[value.id] = { + done: yield* Deferred.make(), + partID: part.id, + messageID: part.messageID, + sessionID: part.sessionID, + } return case "tool-input-delta": @@ -175,14 +285,19 @@ export namespace SessionProcessor { if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } - const match = ctx.toolcalls[value.toolCallId] - if (!match) return - ctx.toolcalls[value.toolCallId] = yield* session.updatePart({ + yield* updateToolCall(value.toolCallId, (match) => ({ ...match, tool: value.toolName, - state: { status: "running", input: value.input, time: { start: Date.now() } }, - metadata: value.providerMetadata, - } satisfies MessageV2.ToolPart) + state: { + ...match.state, + status: "running", + input: value.input, + time: { start: Date.now() }, + }, + metadata: match.metadata?.providerExecuted + ? { ...value.providerMetadata, providerExecuted: true } + : value.providerMetadata, + })) const parts = MessageV2.parts(ctx.assistantMessage.id) const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD) @@ -213,40 +328,12 @@ export namespace SessionProcessor { } case "tool-result": { - const match = ctx.toolcalls[value.toolCallId] - if (!match || match.state.status !== "running") return - yield* session.updatePart({ - ...match, - state: { - status: "completed", - input: value.input ?? match.state.input, - output: value.output.output, - metadata: value.output.metadata, - title: value.output.title, - time: { start: match.state.time.start, end: Date.now() }, - attachments: value.output.attachments, - }, - }) - delete ctx.toolcalls[value.toolCallId] + yield* completeToolCall(value.toolCallId, value.output) return } case "tool-error": { - const match = ctx.toolcalls[value.toolCallId] - if (!match || match.state.status !== "running") return - yield* session.updatePart({ - ...match, - state: { - status: "error", - input: value.input ?? match.state.input, - error: value.error instanceof Error ? value.error.message : String(value.error), - time: { start: match.state.time.start, end: Date.now() }, - }, - }) - if (value.error instanceof Permission.RejectedError || value.error instanceof Question.RejectedError) { - ctx.blocked = ctx.shouldBreak - } - delete ctx.toolcalls[value.toolCallId] + yield* failToolCall(value.toolCallId, value.error) return } @@ -339,7 +426,7 @@ export namespace SessionProcessor { case "text-end": if (!ctx.currentText) return - ctx.currentText.text = ctx.currentText.text.trimEnd() + ctx.currentText.text = ctx.currentText.text ctx.currentText.text = (yield* plugin.trigger( "experimental.text.complete", { @@ -349,7 +436,10 @@ export namespace SessionProcessor { }, { text: ctx.currentText.text }, )).text - ctx.currentText.time = { start: Date.now(), end: Date.now() } + { + const end = Date.now() + ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end } + } if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata yield* session.updatePart(ctx.currentText) ctx.currentText = undefined @@ -359,7 +449,7 @@ export namespace SessionProcessor { return default: - log.info("unhandled", { ...value }) + yield* slog.info("unhandled", { event: value.type, value }) return } }) @@ -396,25 +486,36 @@ export namespace SessionProcessor { } ctx.reasoningMap = {} - const parts = MessageV2.parts(ctx.assistantMessage.id) - for (const part of parts) { - if (part.type !== "tool" || part.state.status === "completed" || part.state.status === "error") continue + yield* Effect.forEach( + Object.values(ctx.toolcalls), + (call) => Deferred.await(call.done).pipe(Effect.timeout("250 millis"), Effect.ignore), + { concurrency: "unbounded" }, + ) + + for (const toolCallID of Object.keys(ctx.toolcalls)) { + const match = yield* readToolCall(toolCallID) + if (!match) continue + const part = match.part + const end = Date.now() + const metadata = "metadata" in part.state && isRecord(part.state.metadata) ? part.state.metadata : {} yield* session.updatePart({ ...part, state: { ...part.state, status: "error", error: "Tool execution aborted", - time: { start: Date.now(), end: Date.now() }, + metadata: { ...metadata, interrupted: true }, + time: { start: "time" in part.state ? part.state.time.start : end, end }, }, }) } + ctx.toolcalls = {} ctx.assistantMessage.time.completed = Date.now() yield* session.updateMessage(ctx.assistantMessage) }) const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) { - log.error("process", { error: e, stack: e instanceof Error ? e.stack : undefined }) + yield* slog.error("process", { error: errorMessage(e), stack: e instanceof Error ? e.stack : undefined }) const error = parse(e) if (MessageV2.ContextOverflowError.isInstance(error)) { ctx.needsCompaction = true @@ -428,21 +529,8 @@ export namespace SessionProcessor { }) }) - const abort = Effect.fn("SessionProcessor.abort")(() => - Effect.gen(function* () { - if (!ctx.assistantMessage.error) { - yield* halt(new DOMException("Aborted", "AbortError")) - } - if (!ctx.assistantMessage.time.completed) { - yield* cleanup() - return - } - yield* session.updateMessage(ctx.assistantMessage) - }), - ) - const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) { - log.info("process") + yield* slog.info("process") ctx.needsCompaction = false ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true @@ -458,7 +546,14 @@ export namespace SessionProcessor { Stream.runDrain, ) }).pipe( - Effect.onInterrupt(() => Effect.sync(() => void (aborted = true))), + Effect.onInterrupt(() => + Effect.gen(function* () { + aborted = true + if (!ctx.assistantMessage.error) { + yield* halt(new DOMException("Aborted", "AbortError")) + } + }), + ), Effect.catchCauseIf( (cause) => !Cause.hasInterruptsOnly(cause), (cause) => Effect.fail(Cause.squash(cause)), @@ -479,23 +574,18 @@ export namespace SessionProcessor { Effect.ensuring(cleanup()), ) - if (aborted && !ctx.assistantMessage.error) { - yield* abort() - } if (ctx.needsCompaction) return "compact" - if (ctx.blocked || ctx.assistantMessage.error || aborted) return "stop" + if (ctx.blocked || ctx.assistantMessage.error) return "stop" return "continue" - }).pipe(Effect.onInterrupt(() => abort().pipe(Effect.asVoid))) + }) }) return { get message() { return ctx.assistantMessage }, - partFromToolCall(toolCallID: string) { - return ctx.toolcalls[toolCallID] - }, - abort, + updateToolCall, + completeToolCall, process, } satisfies Handle }) @@ -504,19 +594,17 @@ export namespace SessionProcessor { }), ) - export const defaultLayer = Layer.unwrap( - Effect.sync(() => - layer.pipe( - Layer.provide(Session.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(LLM.defaultLayer), - Layer.provide(Permission.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(SessionStatus.layer.pipe(Layer.provide(Bus.layer))), - Layer.provide(Bus.layer), - Layer.provide(Config.defaultLayer), - ), + export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(Session.defaultLayer), + Layer.provide(Snapshot.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(LLM.defaultLayer), + Layer.provide(Permission.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(SessionStatus.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(Config.defaultLayer), ), ) } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index bb41a746e..97a37865d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -20,7 +20,6 @@ import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" import { ToolRegistry } from "../tool/registry" -import { Runner } from "@/effect/runner" import { MCP } from "../mcp" import { LSP } from "../lsp" import { FileTime } from "../file/time" @@ -44,10 +43,12 @@ import { AppFileSystem } from "@/filesystem" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" -import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect" +import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect" +import { EffectLogger } from "@/effect/logger" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" -import { TaskTool } from "@/tool/task" +import { TaskTool, type TaskPromptOps } from "@/tool/task" +import { SessionRunState } from "./run-state" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -64,9 +65,9 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) + const elog = EffectLogger.create({ service: "session.prompt" }) export interface Interface { - readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect readonly cancel: (sessionID: SessionID) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect readonly loop: (input: z.infer) => Effect.Effect @@ -75,7 +76,7 @@ export namespace SessionPrompt { readonly resolvePromptParts: (template: string) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/SessionPrompt") {} + export class Service extends Context.Service()("@opencode/SessionPrompt") {} export const layer = Layer.effect( Service, @@ -99,58 +100,20 @@ export namespace SessionPrompt { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const scope = yield* Scope.Scope 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 state = yield* InstanceState.make( - Effect.fn("SessionPrompt.state")(function* () { - const runners = new Map>() - yield* Effect.addFinalizer( - Effect.fnUntraced(function* () { - yield* Effect.forEach(runners.values(), (r) => r.cancel, { concurrency: "unbounded", discard: true }) - runners.clear() - }), - ) - return { runners } - }), - ) - - const getRunner = (runners: Map>, sessionID: SessionID) => { - const existing = runners.get(sessionID) - if (existing) return existing - const runner = Runner.make(scope, { - onIdle: Effect.gen(function* () { - runners.delete(sessionID) - yield* status.set(sessionID, { type: "idle" }) - }), - onBusy: status.set(sessionID, { type: "busy" }), - onInterrupt: lastAssistant(sessionID), - busy: () => { - throw new Session.BusyError(sessionID) - }, - }) - runners.set(sessionID, runner) - return runner + const run = { + promise: (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(EffectLogger.layer))), + fork: (effect: Effect.Effect) => Effect.runFork(effect.pipe(Effect.provide(EffectLogger.layer))), } - const assertNotBusy: (sessionID: SessionID) => Effect.Effect = Effect.fn( - "SessionPrompt.assertNotBusy", - )(function* (sessionID: SessionID) { - const s = yield* InstanceState.get(state) - const runner = s.runners.get(sessionID) - if (runner?.busy) throw new Session.BusyError(sessionID) - }) - const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) { - log.info("cancel", { sessionID }) - const s = yield* InstanceState.get(state) - const runner = s.runners.get(sessionID) - if (!runner || !runner.busy) { - const current = yield* status.get(sessionID) - if (current.type !== "idle") { - yield* status.set(sessionID, { type: "idle" }) - } - return - } - yield* runner.cancel + yield* elog.info("cancel", { sessionID }) + yield* state.cancel(sessionID) }) const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { @@ -219,21 +182,24 @@ 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* Effect.promise(async (signal) => { - const result = await LLM.stream({ + const text = yield* 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], }) - return result.text - }) + .pipe( + Stream.filter((e): e is Extract => e.type === "text-delta"), + Stream.map((e) => e.text), + Stream.mkString, + Effect.orDie, + ) const cleaned = text .replace(/[\s\S]*?<\/think>\s*/g, "") .split("\n") @@ -243,11 +209,7 @@ export namespace SessionPrompt { const t = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned yield* sessions .setTitle({ sessionID: input.session.id, title: t }) - .pipe( - Effect.catchCause((cause) => - Effect.sync(() => log.error("failed to generate title", { error: Cause.squash(cause) })), - ), - ) + .pipe(Effect.catchCause((cause) => elog.error("failed to generate title", { error: Cause.squash(cause) }))) }) const insertReminders = Effect.fn("SessionPrompt.insertReminders")(function* (input: { @@ -391,7 +353,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the model: Provider.Model session: Session.Info tools?: Record - processor: Pick + processor: Pick bypassAgentCheck: boolean messages: MessageV2.WithParts[] }) { @@ -403,35 +365,32 @@ NOTE: At any point in time through this workflow you should feel free to ask the abort: options.abortSignal!, messageID: input.processor.message.id, callID: options.toolCallId, - extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck }, + extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps }, agent: input.agent.name, messages: input.messages, metadata: (val) => - Effect.runPromise( - Effect.gen(function* () { - const match = input.processor.partFromToolCall(options.toolCallId) - if (!match || !["running", "pending"].includes(match.state.status)) return - yield* sessions.updatePart({ - ...match, - state: { - title: val.title, - metadata: val.metadata, - status: "running", - input: args, - time: { start: Date.now() }, - }, - }) - }), - ), + 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) => - Effect.runPromise( - permission.ask({ + permission + .ask({ ...req, sessionID: input.session.id, tool: { messageID: input.processor.message.id, callID: options.toolCallId }, ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []), - }), - ), + }) + .pipe(Effect.orDie), }) for (const item of yield* registry.tools({ @@ -445,7 +404,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the description: item.description, inputSchema: jsonSchema(schema as any), execute(args, options) { - return Effect.runPromise( + return run.promise( Effect.gen(function* () { const ctx = context(args, options) yield* plugin.trigger( @@ -453,7 +412,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID }, { args }, ) - const result = yield* Effect.promise(() => item.execute(args, ctx)) + const result = yield* item.execute(args, ctx) const output = { ...result, attachments: result.attachments?.map((attachment) => ({ @@ -468,6 +427,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args }, output, ) + if (options.abortSignal?.aborted) { + yield* input.processor.completeToolCall(options.toolCallId, output) + } return output }), ) @@ -483,7 +445,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const transformed = ProviderTransform.schema(input.model, schema) item.inputSchema = jsonSchema(transformed) item.execute = (args, opts) => - Effect.runPromise( + run.promise( Effect.gen(function* () { const ctx = context(args, opts) yield* plugin.trigger( @@ -491,7 +453,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, { args }, ) - yield* Effect.promise(() => ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })) + yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) const result: Awaited>> = yield* Effect.promise(() => execute(args, opts), ) @@ -532,7 +494,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the ...(truncated.truncated && { outputPath: truncated.outputPath }), } - return { + const output = { title: "", metadata, output: truncated.content, @@ -544,6 +506,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the })), content: result.content, } + if (opts.abortSignal?.aborted) { + yield* input.processor.completeToolCall(opts.toolCallId, output) + } + return output }), ) tools[key] = item @@ -562,7 +528,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) { const { task, model, lastUser, sessionID, session, msgs } = input const ctx = yield* InstanceState.context - const taskTool = yield* registry.fromID(TaskTool.id) + const { task: taskTool } = yield* registry.named() const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({ id: MessageID.ascending(), @@ -603,7 +569,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the subagent_type: task.agent, command: task.command, } - yield* plugin.trigger("tool.execute.before", { tool: "task", sessionID, callID: part.id }, { args: taskArgs }) + yield* plugin.trigger( + "tool.execute.before", + { tool: TaskTool.id, sessionID, callID: part.id }, + { args: taskArgs }, + ) const taskAgent = yield* agents.get(task.agent) if (!taskAgent) { @@ -615,63 +585,61 @@ NOTE: At any point in time through this workflow you should feel free to ask the } let error: Error | undefined - const result = yield* Effect.promise((signal) => - taskTool - .execute(taskArgs, { - agent: task.agent, - messageID: assistantMessage.id, - sessionID, - abort: signal, - callID: part.callID, - extra: { bypassAgentCheck: true }, - messages: msgs, - metadata(val: { title?: string; metadata?: Record }) { - return Effect.runPromise( - Effect.gen(function* () { - part = yield* sessions.updatePart({ - ...part, - type: "tool", - state: { ...part.state, ...val }, - } satisfies MessageV2.ToolPart) - }), - ) - }, - ask(req: any) { - return Effect.runPromise( - permission.ask({ - ...req, - sessionID, - ruleset: Permission.merge(taskAgent.permission, session.permission ?? []), - }), - ) - }, - }) - .catch((e) => { - error = e instanceof Error ? e : new Error(String(e)) - log.error("subtask execution failed", { error, agent: task.agent, description: task.description }) - return undefined - }), - ).pipe( - Effect.onInterrupt(() => - Effect.gen(function* () { - assistantMessage.finish = "tool-calls" - assistantMessage.time.completed = Date.now() - yield* sessions.updateMessage(assistantMessage) - if (part.state.status === "running") { - yield* sessions.updatePart({ + const taskAbort = new AbortController() + const result = yield* taskTool + .execute(taskArgs, { + agent: task.agent, + messageID: assistantMessage.id, + sessionID, + abort: taskAbort.signal, + callID: part.callID, + extra: { bypassAgentCheck: true, promptOps }, + messages: msgs, + metadata: (val: { title?: string; metadata?: Record }) => + Effect.gen(function* () { + part = yield* sessions.updatePart({ ...part, - state: { - status: "error", - error: "Cancelled", - time: { start: part.state.time.start, end: Date.now() }, - metadata: part.state.metadata, - input: part.state.input, - }, + type: "tool", + state: { ...part.state, ...val }, } satisfies MessageV2.ToolPart) - } + }), + ask: (req: any) => + permission + .ask({ + ...req, + sessionID, + ruleset: Permission.merge(taskAgent.permission, session.permission ?? []), + }) + .pipe(Effect.orDie), + }) + .pipe( + Effect.catchCause((cause) => { + const defect = Cause.squash(cause) + error = defect instanceof Error ? defect : new Error(String(defect)) + log.error("subtask execution failed", { error, agent: task.agent, description: task.description }) + return Effect.void }), - ), - ) + Effect.onInterrupt(() => + Effect.gen(function* () { + taskAbort.abort() + assistantMessage.finish = "tool-calls" + assistantMessage.time.completed = Date.now() + yield* sessions.updateMessage(assistantMessage) + if (part.state.status === "running") { + yield* sessions.updatePart({ + ...part, + state: { + status: "error", + error: "Cancelled", + time: { start: part.state.time.start, end: Date.now() }, + metadata: part.state.metadata, + input: part.state.input, + }, + } satisfies MessageV2.ToolPart) + } + }), + ), + ) const attachments = result?.attachments?.map((attachment) => ({ ...attachment, @@ -682,7 +650,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* plugin.trigger( "tool.execute.after", - { tool: "task", sessionID, callID: part.id, args: taskArgs }, + { tool: TaskTool.id, sessionID, callID: part.id, args: taskArgs }, result, ) @@ -742,11 +710,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the } satisfies MessageV2.TextPart) }) - const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput, signal: AbortSignal) { + const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput) { const ctx = yield* InstanceState.context const session = yield* sessions.get(input.sessionID) if (session.revert) { - yield* Effect.promise(() => SessionRevert.cleanup(session)) + yield* revert.cleanup(session) } const agent = yield* agents.get(input.agent) if (!agent) { @@ -894,7 +862,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the output += chunk if (part.state.status === "running") { part.state.metadata = { output, description: "" } - void Effect.runFork(sessions.updatePart(part)) + void run.fork(sessions.updatePart(part)) } }), ) @@ -939,12 +907,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) const lastModel = Effect.fnUntraced(function* (sessionID: SessionID) { - const model = yield* Effect.promise(async () => { - for await (const item of MessageV2.stream(sessionID)) { - if (item.info.role === "user" && item.info.model) return item.info.model - } - }) - if (model) return model + 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 return yield* provider.defaultModel() }) @@ -963,9 +927,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID const full = !input.variant && ag.variant && same - ? yield* provider - .getModel(model.providerID, model.modelID) - .pipe(Effect.catch(() => Effect.succeed(undefined))) + ? yield* provider.getModel(model.providerID, model.modelID).pipe(Effect.catchDefect(() => Effect.void)) : undefined const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined) @@ -985,9 +947,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the format: input.format, } - yield* Effect.addFinalizer(() => - InstanceState.withALS(() => instruction.clear(info.id)).pipe(Effect.flatMap((x) => x)), - ) + yield* Effect.addFinalizer(() => instruction.clear(info.id)) type Draft = T extends MessageV2.Part ? Omit & { id?: string } : never const assign = (part: Draft): MessageV2.Part => ({ @@ -1079,6 +1039,23 @@ NOTE: At any point in time through this workflow you should feel free to ask the const filepath = fileURLToPath(part.url) if (yield* fsys.isDir(filepath)) part.mime = "application/x-directory" + const { read } = yield* registry.named() + const execRead = (args: Parameters[0], extra?: Tool.Context["extra"]) => { + const controller = new AbortController() + return read + .execute(args, { + sessionID: input.sessionID, + abort: controller.signal, + agent: input.agent!, + messageID: info.id, + extra: { bypassCwdCheck: true, ...extra }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }) + .pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort()))) + } + if (part.mime === "text/plain") { let offset: number | undefined let limit: number | undefined @@ -1115,29 +1092,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, }, ] - const read = yield* registry.fromID("read").pipe( - Effect.flatMap((t) => - provider.getModel(info.model.providerID, info.model.modelID).pipe( - Effect.flatMap((mdl) => - Effect.promise(() => - t.execute(args, { - sessionID: input.sessionID, - abort: new AbortController().signal, - agent: input.agent!, - messageID: info.id, - extra: { bypassCwdCheck: true, model: mdl }, - messages: [], - metadata: async () => {}, - ask: async () => {}, - }), - ), - ), - ), - ), + const exit = yield* provider.getModel(info.model.providerID, info.model.modelID).pipe( + Effect.flatMap((mdl) => execRead(args, { model: mdl })), Effect.exit, ) - if (Exit.isSuccess(read)) { - const result = read.value + if (Exit.isSuccess(exit)) { + const result = exit.value pieces.push({ messageID: info.id, sessionID: input.sessionID, @@ -1159,7 +1119,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) } } else { - const error = Cause.squash(read.cause) + const error = Cause.squash(exit.cause) log.error("failed to read file", { error }) const message = error instanceof Error ? error.message : String(error) yield* bus.publish(Session.Event.Error, { @@ -1179,22 +1139,25 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (part.mime === "application/x-directory") { const args = { filePath: filepath } - const result = yield* registry.fromID("read").pipe( - Effect.flatMap((t) => - Effect.promise(() => - t.execute(args, { - sessionID: input.sessionID, - abort: new AbortController().signal, - agent: input.agent!, - messageID: info.id, - extra: { bypassCwdCheck: true }, - messages: [], - metadata: async () => {}, - ask: async () => {}, - }), - ), - ), - ) + const exit = yield* execRead(args).pipe(Effect.exit) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + log.error("failed to read directory", { error }) + const message = error instanceof Error ? error.message : String(error) + yield* bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: new NamedError.Unknown({ message }).toObject(), + }) + return [ + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Read tool failed to read ${filepath} with the following error: ${message}`, + }, + ] + } return [ { messageID: info.id, @@ -1208,7 +1171,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the sessionID: input.sessionID, type: "text", synthetic: true, - text: result.output, + text: exit.value.output, }, { ...part, messageID: info.id, sessionID: input.sessionID }, ] @@ -1310,7 +1273,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const prompt: (input: PromptInput) => Effect.Effect = Effect.fn("SessionPrompt.prompt")( function* (input: PromptInput) { const session = yield* sessions.get(input.sessionID) - yield* Effect.promise(() => SessionRevert.cleanup(session)) + yield* revert.cleanup(session) const message = yield* createUserMessage(input) yield* sessions.touch(input.sessionID) @@ -1328,27 +1291,25 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, ) - const lastAssistant = (sessionID: SessionID) => - Effect.promise(async () => { - let latest: MessageV2.WithParts | undefined - for await (const item of MessageV2.stream(sessionID)) { - latest ??= item - if (item.info.role !== "user") return item - } - if (latest) return latest - throw new Error("Impossible") - }) + const lastAssistant = Effect.fnUntraced(function* (sessionID: SessionID) { + const match = yield* sessions.findMessage(sessionID, (m) => m.info.role !== "user") + if (Option.isSome(match)) return match.value + const msgs = yield* sessions.messages({ sessionID, limit: 1 }) + if (msgs.length > 0) return msgs[0] + throw new Error("Impossible") + }) const runLoop: (sessionID: SessionID) => Effect.Effect = Effect.fn("SessionPrompt.run")( function* (sessionID: SessionID) { const ctx = yield* InstanceState.context + const slog = elog.with({ sessionID }) let structured: unknown | undefined let step = 0 const session = yield* sessions.get(sessionID) while (true) { yield* status.set(sessionID, { type: "busy" }) - log.info("loop", { step, sessionID }) + yield* slog.info("loop", { step }) let msgs = yield* MessageV2.filterCompactedEffect(sessionID) @@ -1373,7 +1334,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the ) // Some providers return "stop" even when the assistant message contains tool calls. // Keep the loop running so tool results can be sent back to the model. - const hasToolCalls = lastAssistantMsg?.parts.some((part) => part.type === "tool") ?? false + // Skip provider-executed tool parts — those were fully handled within the + // provider's stream (e.g. DWS Agent Platform) and don't need a re-loop. + const hasToolCalls = + lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false if ( lastAssistant?.finish && @@ -1381,7 +1345,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the !hasToolCalls && lastUser.id < lastAssistant.id ) { - log.info("exiting loop", { sessionID }) + yield* slog.info("exiting loop") break } @@ -1457,110 +1421,104 @@ NOTE: At any point in time through this workflow you should feel free to ask the model, }) - const outcome: "break" | "continue" = yield* Effect.onExit( - Effect.gen(function* () { - const lastUserMsg = msgs.findLast((m) => m.info.role === "user") - const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false + const outcome: "break" | "continue" = yield* Effect.gen(function* () { + const lastUserMsg = msgs.findLast((m) => m.info.role === "user") + const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false - const tools = yield* resolveTools({ - agent, - session, - model, - tools: lastUser.tools, - processor: handle, - bypassAgentCheck, - messages: msgs, + const tools = yield* resolveTools({ + agent, + session, + model, + tools: lastUser.tools, + processor: handle, + bypassAgentCheck, + messages: msgs, + }) + + if (lastUser.format?.type === "json_schema") { + tools["StructuredOutput"] = createStructuredOutputTool({ + schema: lastUser.format.schema, + onSuccess(output) { + structured = output + }, }) + } - if (lastUser.format?.type === "json_schema") { - tools["StructuredOutput"] = createStructuredOutputTool({ - schema: lastUser.format.schema, - onSuccess(output) { - structured = output - }, - }) - } + if (step === 1) SessionSummary.summarize({ sessionID, messageID: lastUser.id }) - if (step === 1) SessionSummary.summarize({ sessionID, messageID: lastUser.id }) - - if (step > 1 && lastFinished) { - for (const m of msgs) { - if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue - for (const p of m.parts) { - if (p.type !== "text" || p.ignored || p.synthetic) continue - if (!p.text.trim()) continue - p.text = [ - "", - "The user sent the following message:", - p.text, - "", - "Please address this message and continue with your tasks.", - "", - ].join("\n") - } + if (step > 1 && lastFinished) { + for (const m of msgs) { + if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue + for (const p of m.parts) { + if (p.type !== "text" || p.ignored || p.synthetic) continue + if (!p.text.trim()) continue + p.text = [ + "", + "The user sent the following message:", + p.text, + "", + "Please address this message and continue with your tasks.", + "", + ].join("\n") } } + } - yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) + yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - const [skills, env, instructions, modelMsgs] = yield* Effect.all([ - Effect.promise(() => SystemPrompt.skills(agent)), - Effect.promise(() => SystemPrompt.environment(model)), - instruction.system().pipe(Effect.orDie), - Effect.promise(() => MessageV2.toModelMessages(msgs, model)), - ]) - const system = [...env, ...(skills ? [skills] : []), ...instructions] - const format = lastUser.format ?? { type: "text" as const } - if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) - const result = yield* handle.process({ - user: lastUser, - agent, - permission: session.permission, - sessionID, - parentSessionID: session.parentID, - system, - messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])], - tools, - model, - toolChoice: format.type === "json_schema" ? "required" : undefined, - }) + const [skills, env, instructions, modelMsgs] = yield* Effect.all([ + sys.skills(agent), + Effect.sync(() => sys.environment(model)), + instruction.system().pipe(Effect.orDie), + MessageV2.toModelMessagesEffect(msgs, model), + ]) + const system = [...env, ...(skills ? [skills] : []), ...instructions] + const format = lastUser.format ?? { type: "text" as const } + if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) + const result = yield* handle.process({ + user: lastUser, + agent, + permission: session.permission, + sessionID, + parentSessionID: session.parentID, + system, + messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])], + tools, + model, + toolChoice: format.type === "json_schema" ? "required" : undefined, + }) - if (structured !== undefined) { - handle.message.structured = structured - handle.message.finish = handle.message.finish ?? "stop" + if (structured !== undefined) { + handle.message.structured = structured + handle.message.finish = handle.message.finish ?? "stop" + yield* sessions.updateMessage(handle.message) + return "break" as const + } + + const finished = handle.message.finish && !["tool-calls", "unknown"].includes(handle.message.finish) + if (finished && !handle.message.error) { + if (format.type === "json_schema") { + handle.message.error = new MessageV2.StructuredOutputError({ + message: "Model did not produce structured output", + retries: 0, + }).toObject() yield* sessions.updateMessage(handle.message) return "break" as const } + } - const finished = handle.message.finish && !["tool-calls", "unknown"].includes(handle.message.finish) - if (finished && !handle.message.error) { - if (format.type === "json_schema") { - handle.message.error = new MessageV2.StructuredOutputError({ - message: "Model did not produce structured output", - retries: 0, - }).toObject() - yield* sessions.updateMessage(handle.message) - return "break" as const - } - } - - if (result === "stop") return "break" as const - if (result === "compact") { - yield* compaction.create({ - sessionID, - agent: lastUser.agent, - model: lastUser.model, - auto: true, - overflow: !handle.message.finish, - }) - } - return "continue" as const - }), - Effect.fnUntraced(function* (exit) { - if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) yield* handle.abort() - yield* InstanceState.withALS(() => instruction.clear(handle.message.id)).pipe(Effect.flatMap((x) => x)) - }), - ) + if (result === "stop") return "break" as const + if (result === "compact") { + yield* compaction.create({ + sessionID, + agent: lastUser.agent, + model: lastUser.model, + auto: true, + overflow: !handle.message.finish, + }) + } + return "continue" as const + }).pipe(Effect.ensuring(instruction.clear(handle.message.id))) if (outcome === "break") break continue } @@ -1573,21 +1531,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the const loop: (input: z.infer) => Effect.Effect = Effect.fn( "SessionPrompt.loop", )(function* (input: z.infer) { - const s = yield* InstanceState.get(state) - const runner = getRunner(s.runners, input.sessionID) - return yield* runner.ensureRunning(runLoop(input.sessionID)) + return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID)) }) const shell: (input: ShellInput) => Effect.Effect = Effect.fn("SessionPrompt.shell")( function* (input: ShellInput) { - const s = yield* InstanceState.get(state) - const runner = getRunner(s.runners, input.sessionID) - return yield* runner.startShell((signal) => shellImpl(input, signal)) + return yield* state.startShell(input.sessionID, lastAssistant(input.sessionID), shellImpl(input)) }, ) const command = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) { - log.info("command", input) + yield* elog.info("command", { sessionID: input.sessionID, command: input.command, agent: input.agent }) const cmd = yield* commands.get(input.command) if (!cmd) { const available = (yield* commands.list()).map((c) => c.name) @@ -1702,8 +1656,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the return result }) + const promptOps: TaskPromptOps = { + cancel: (sessionID) => run.fork(cancel(sessionID)), + resolvePromptParts: (template) => resolvePromptParts(template), + prompt: (input) => prompt(input), + } + return Service.of({ - assertNotBusy, cancel, prompt, loop, @@ -1714,36 +1673,38 @@ NOTE: At any point in time through this workflow you should feel free to ask the }), ) - const defaultLayer = Layer.unwrap( - Effect.sync(() => - layer.pipe( - Layer.provide(SessionStatus.layer), - Layer.provide(SessionCompaction.defaultLayer), - Layer.provide(SessionProcessor.defaultLayer), - Layer.provide(Command.defaultLayer), - Layer.provide(Permission.defaultLayer), - Layer.provide(MCP.defaultLayer), - Layer.provide(LSP.defaultLayer), - Layer.provide(FileTime.defaultLayer), - Layer.provide(ToolRegistry.defaultLayer), - Layer.provide(Truncate.layer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Instruction.defaultLayer), - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Bus.layer), - Layer.provide(CrossSpawnSpawner.defaultLayer), + export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(SessionRunState.defaultLayer), + Layer.provide(SessionStatus.defaultLayer), + Layer.provide(SessionCompaction.defaultLayer), + Layer.provide(SessionProcessor.defaultLayer), + Layer.provide(Command.defaultLayer), + Layer.provide(Permission.defaultLayer), + Layer.provide(MCP.defaultLayer), + Layer.provide(LSP.defaultLayer), + Layer.provide(FileTime.defaultLayer), + Layer.provide(ToolRegistry.defaultLayer), + Layer.provide(Truncate.defaultLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(Instruction.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + 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, + ), ), ), ) const { runPromise } = makeRuntime(Service, defaultLayer) - export async function assertNotBusy(sessionID: SessionID) { - return runPromise((svc) => svc.assertNotBusy(SessionID.zod.parse(sessionID))) - } - export const PromptInput = z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional(), diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 16fec29f3..5ec9a585b 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -6,6 +6,10 @@ import { iife } from "@/util/iife" export namespace SessionRetry { export type Err = ReturnType + // This exported message is shared with the TUI upsell detector. Matching on a + // literal error string kind of sucks, but it is the simplest for now. + export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go https://opencode.ai/go" + export const RETRY_INITIAL_DELAY = 2000 export const RETRY_BACKOFF_FACTOR = 2 export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds @@ -53,8 +57,7 @@ export namespace SessionRetry { if (MessageV2.ContextOverflowError.isInstance(error)) return undefined if (MessageV2.APIError.isInstance(error)) { if (!error.data.isRetryable) return undefined - if (error.data.responseBody?.includes("FreeUsageLimitError")) - return `Free usage exceeded, subscribe to Go https://opencode.ai/go` + if (error.data.responseBody?.includes("FreeUsageLimitError")) return GO_UPSELL_MESSAGE return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message } diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 9df3f36eb..416b8555d 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -1,5 +1,5 @@ import z from "zod" -import { Effect, Layer, ServiceMap } from "effect" +import { Effect, Layer, Context } from "effect" import { makeRuntime } from "@/effect/run-service" import { Bus } from "../bus" import { Snapshot } from "../snapshot" @@ -9,8 +9,9 @@ import { Log } from "../util/log" import { Session } from "." import { MessageV2 } from "./message-v2" import { SessionID, MessageID, PartID } from "./schema" -import { SessionPrompt } from "./prompt" +import { SessionRunState } from "./run-state" import { SessionSummary } from "./summary" +import { SessionStatus } from "./status" export namespace SessionRevert { const log = Log.create({ service: "session.revert" }) @@ -28,7 +29,7 @@ export namespace SessionRevert { readonly cleanup: (session: Session.Info) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/SessionRevert") {} + export class Service extends Context.Service()("@opencode/SessionRevert") {} export const layer = Layer.effect( Service, @@ -38,9 +39,10 @@ export namespace SessionRevert { const storage = yield* Storage.Service const bus = yield* Bus.Service const summary = yield* SessionSummary.Service + const state = yield* SessionRunState.Service const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) { - yield* Effect.promise(() => SessionPrompt.assertNotBusy(input.sessionID)) + yield* state.assertNotBusy(input.sessionID) const all = yield* sessions.messages({ sessionID: input.sessionID }) let lastUser: MessageV2.User | undefined const session = yield* sessions.get(input.sessionID) @@ -93,7 +95,7 @@ export namespace SessionRevert { const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) { log.info("unreverting", input) - yield* Effect.promise(() => SessionPrompt.assertNotBusy(input.sessionID)) + yield* state.assertNotBusy(input.sessionID) const session = yield* sessions.get(input.sessionID) if (!session.revert) return session if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!) @@ -148,15 +150,14 @@ export namespace SessionRevert { }), ) - export const defaultLayer = Layer.unwrap( - Effect.sync(() => - layer.pipe( - Layer.provide(Session.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(Storage.defaultLayer), - Layer.provide(Bus.layer), - Layer.provide(SessionSummary.defaultLayer), - ), + export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(SessionRunState.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(Snapshot.defaultLayer), + Layer.provide(Storage.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(SessionSummary.defaultLayer), ), ) diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts new file mode 100644 index 000000000..66ce4ce65 --- /dev/null +++ b/packages/opencode/src/session/run-state.ts @@ -0,0 +1,111 @@ +import { InstanceState } from "@/effect/instance-state" +import { Runner } from "@/effect/runner" +import { Effect, Layer, Scope, Context } from "effect" +import { Session } from "." +import { MessageV2 } from "./message-v2" +import { SessionID } from "./schema" +import { SessionStatus } from "./status" + +export namespace SessionRunState { + export interface Interface { + readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect + readonly cancel: (sessionID: SessionID) => Effect.Effect + readonly ensureRunning: ( + sessionID: SessionID, + onInterrupt: Effect.Effect, + work: Effect.Effect, + ) => Effect.Effect + readonly startShell: ( + sessionID: SessionID, + onInterrupt: Effect.Effect, + work: Effect.Effect, + ) => Effect.Effect + } + + export class Service extends Context.Service()("@opencode/SessionRunState") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const status = yield* SessionStatus.Service + + const state = yield* InstanceState.make( + Effect.fn("SessionRunState.state")(function* () { + const scope = yield* Scope.Scope + const runners = new Map>() + yield* Effect.addFinalizer( + Effect.fnUntraced(function* () { + yield* Effect.forEach(runners.values(), (runner) => runner.cancel, { + concurrency: "unbounded", + discard: true, + }) + runners.clear() + }), + ) + return { runners, scope } + }), + ) + + const runner = Effect.fn("SessionRunState.runner")(function* ( + sessionID: SessionID, + onInterrupt: Effect.Effect, + ) { + const data = yield* InstanceState.get(state) + const existing = data.runners.get(sessionID) + if (existing) return existing + const next = Runner.make(data.scope, { + onIdle: Effect.gen(function* () { + data.runners.delete(sessionID) + yield* status.set(sessionID, { type: "idle" }) + }), + onBusy: status.set(sessionID, { type: "busy" }), + onInterrupt, + busy: () => { + throw new Session.BusyError(sessionID) + }, + }) + data.runners.set(sessionID, next) + return next + }) + + const assertNotBusy = Effect.fn("SessionRunState.assertNotBusy")(function* (sessionID: SessionID) { + const data = yield* InstanceState.get(state) + const existing = data.runners.get(sessionID) + if (existing?.busy) throw new Session.BusyError(sessionID) + }) + + const cancel = Effect.fn("SessionRunState.cancel")(function* (sessionID: SessionID) { + const data = yield* InstanceState.get(state) + const existing = data.runners.get(sessionID) + if (!existing || !existing.busy) { + const current = yield* status.get(sessionID) + if (current.type !== "idle") { + yield* status.set(sessionID, { type: "idle" }) + } + return + } + yield* existing.cancel + }) + + const ensureRunning = Effect.fn("SessionRunState.ensureRunning")(function* ( + sessionID: SessionID, + onInterrupt: Effect.Effect, + work: Effect.Effect, + ) { + return yield* (yield* runner(sessionID, onInterrupt)).ensureRunning(work) + }) + + const startShell = Effect.fn("SessionRunState.startShell")(function* ( + sessionID: SessionID, + onInterrupt: Effect.Effect, + work: Effect.Effect, + ) { + return yield* (yield* runner(sessionID, onInterrupt)).startShell(work) + }) + + return Service.of({ assertNotBusy, cancel, ensureRunning, startShell }) + }), + ) + + export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer)) +} diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index 540643c49..856ab3114 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -7,8 +7,7 @@ import { withStatics } from "@/util/schema" export const SessionID = Schema.String.pipe( Schema.brand("SessionID"), withStatics((s) => ({ - make: (id: string) => s.makeUnsafe(id), - descending: (id?: string) => s.makeUnsafe(Identifier.descending("session", id)), + descending: (id?: string) => s.make(Identifier.descending("session", id)), zod: Identifier.schema("session").pipe(z.custom>()), })), ) @@ -18,8 +17,7 @@ export type SessionID = Schema.Schema.Type export const MessageID = Schema.String.pipe( Schema.brand("MessageID"), withStatics((s) => ({ - make: (id: string) => s.makeUnsafe(id), - ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("message", id)), + ascending: (id?: string) => s.make(Identifier.ascending("message", id)), zod: Identifier.schema("message").pipe(z.custom>()), })), ) @@ -29,8 +27,7 @@ export type MessageID = Schema.Schema.Type export const PartID = Schema.String.pipe( Schema.brand("PartID"), withStatics((s) => ({ - make: (id: string) => s.makeUnsafe(id), - ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("part", id)), + ascending: (id?: string) => s.make(Identifier.ascending("part", id)), zod: Identifier.schema("part").pipe(z.custom>()), })), ) diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 93e0d6ccf..fcf1481dc 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -1,10 +1,9 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" import { Log } from "@/util/log" import { SessionID } from "./schema" -import { Effect, Layer, ServiceMap } from "effect" +import { Effect, Layer, Context } from "effect" import z from "zod" const log = Log.create({ service: "session-status" }) @@ -53,7 +52,7 @@ export namespace SessionStatus { readonly set: (sessionID: SessionID, status: Info) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/SessionStatus") {} + export class Service extends Context.Service()("@opencode/SessionStatus") {} export const layer = Layer.effect( Service, @@ -94,18 +93,5 @@ export namespace SessionStatus { }), ) - const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function get(sessionID: SessionID) { - return runPromise((svc) => svc.get(sessionID)) - } - - export async function list() { - return runPromise((svc) => svc.list()) - } - - export async function set(sessionID: SessionID, status: Info) { - return runPromise((svc) => svc.set(sessionID, status)) - } + export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) } diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index f2b53f3ba..498288d61 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -1,5 +1,5 @@ import z from "zod" -import { Effect, Layer, ServiceMap } from "effect" +import { Effect, Layer, Context } from "effect" import { makeRuntime } from "@/effect/run-service" import { Bus } from "@/bus" import { Snapshot } from "@/snapshot" @@ -71,7 +71,7 @@ export namespace SessionSummary { readonly computeDiff: (input: { messages: MessageV2.WithParts[] }) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/SessionSummary") {} + export class Service extends Context.Service()("@opencode/SessionSummary") {} export const layer = Layer.effect( Service, @@ -150,14 +150,12 @@ export namespace SessionSummary { }), ) - export const defaultLayer = Layer.unwrap( - Effect.sync(() => - layer.pipe( - Layer.provide(Session.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(Storage.defaultLayer), - Layer.provide(Bus.layer), - ), + export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(Session.defaultLayer), + Layer.provide(Snapshot.defaultLayer), + Layer.provide(Storage.defaultLayer), + Layer.provide(Bus.layer), ), ) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 09788f3cd..2a001ba9b 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -1,4 +1,4 @@ -import { Ripgrep } from "../file/ripgrep" +import { Context, Effect, Layer } from "effect" import { Instance } from "../project/instance" @@ -33,44 +33,52 @@ export namespace SystemPrompt { return [PROMPT_DEFAULT] } - 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:`, - ``, - ` 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()}`, - ``, - ``, - ` ${ - project.vcs === "git" && false - ? await Ripgrep.tree({ - cwd: Instance.directory, - limit: 50, - }) - : "" - }`, - ``, - ].join("\n"), - ] + export interface Interface { + readonly environment: (model: Provider.Model) => string[] + readonly skills: (agent: Agent.Info) => Effect.Effect } - export async function skills(agent: Agent.Info) { - if (Permission.disabled(["skill"], agent.permission).has("skill")) return + export class Service extends Context.Service()("@opencode/SystemPrompt") {} - const list = await Skill.available(agent) + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const skill = yield* Skill.Service - 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") - } + 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:`, + ``, + ` 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()}`, + ``, + ].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)) } diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 2d85ad224..1fd9cbaa5 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -1,8 +1,7 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" -import { makeRuntime } from "@/effect/run-service" import { SessionID } from "./schema" -import { Effect, Layer, ServiceMap } from "effect" +import { Effect, Layer, Context } from "effect" import z from "zod" import { Database, eq, asc } from "../storage/db" import { TodoTable } from "./session.sql" @@ -32,7 +31,7 @@ export namespace Todo { readonly get: (sessionID: SessionID) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/SessionTodo") {} + export class Service extends Context.Service()("@opencode/SessionTodo") {} export const layer = Layer.effect( Service, @@ -83,13 +82,4 @@ export namespace Todo { ) export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function update(input: { sessionID: SessionID; todos: Info[] }) { - return runPromise((svc) => svc.update(input)) - } - - export async function get(sessionID: SessionID) { - return runPromise((svc) => svc.get(sessionID)) - } } diff --git a/packages/opencode/src/share/session.ts b/packages/opencode/src/share/session.ts new file mode 100644 index 000000000..f98bf14cb --- /dev/null +++ b/packages/opencode/src/share/session.ts @@ -0,0 +1,67 @@ +import { makeRuntime } from "@/effect/run-service" +import { Session } from "@/session" +import { SessionID } from "@/session/schema" +import { SyncEvent } from "@/sync" +import { fn } from "@/util/fn" +import { Effect, Layer, Scope, Context } from "effect" +import { Config } from "../config/config" +import { Flag } from "../flag/flag" +import { ShareNext } from "./share-next" + +export namespace SessionShare { + export interface Interface { + readonly create: (input?: Parameters[0]) => Effect.Effect + readonly share: (sessionID: SessionID) => Effect.Effect<{ url: string }, unknown> + readonly unshare: (sessionID: SessionID) => Effect.Effect + } + + export class Service extends Context.Service()("@opencode/SessionShare") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const cfg = yield* Config.Service + const session = yield* Session.Service + const shareNext = yield* ShareNext.Service + const scope = yield* Scope.Scope + + const share = Effect.fn("SessionShare.share")(function* (sessionID: SessionID) { + const conf = yield* cfg.get() + if (conf.share === "disabled") throw new Error("Sharing is disabled in configuration") + const result = yield* shareNext.create(sessionID) + yield* Effect.sync(() => + SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: result.url } } }), + ) + return result + }) + + const unshare = Effect.fn("SessionShare.unshare")(function* (sessionID: SessionID) { + yield* shareNext.remove(sessionID) + yield* Effect.sync(() => SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } })) + }) + + const create = Effect.fn("SessionShare.create")(function* (input?: Parameters[0]) { + const result = yield* session.create(input) + if (result.parentID) return result + const conf = yield* cfg.get() + if (!(Flag.OPENCODE_AUTO_SHARE || conf.share === "auto")) return result + yield* share(result.id).pipe(Effect.ignore, Effect.forkIn(scope)) + return result + }) + + return Service.of({ create, share, unshare }) + }), + ) + + export const defaultLayer = layer.pipe( + Layer.provide(ShareNext.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(Config.defaultLayer), + ) + + const { runPromise } = makeRuntime(Service, defaultLayer) + + export const create = fn(Session.create.schema, (input) => runPromise((svc) => svc.create(input))) + export const share = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.share(sessionID))) + export const unshare = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.unshare(sessionID))) +} diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 0cd0055c8..ad247f546 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -1,10 +1,9 @@ import type * as SDK from "@opencode-ai/sdk/v2" -import { Effect, Exit, Layer, Option, Schema, Scope, ServiceMap, Stream } from "effect" +import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Account } from "@/account" import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" import { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Session } from "@/session" @@ -74,7 +73,7 @@ export namespace ShareNext { readonly remove: (sessionID: SessionID) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/ShareNext") {} + export class Service extends Context.Service()("@opencode/ShareNext") {} const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => Effect.sync(() => Database.use(fn)) @@ -159,7 +158,10 @@ export namespace ShareNext { if (disabled) return cache - const watch = (def: D, fn: (evt: { properties: any }) => Effect.Effect) => + const watch = ( + def: D, + fn: (evt: { properties: any }) => Effect.Effect, + ) => bus.subscribe(def as never).pipe( Stream.runForEach((evt) => fn(evt).pipe( @@ -194,6 +196,7 @@ export namespace ShareNext { yield* watch(Session.Event.Diff, (evt) => sync(evt.properties.sessionID, [{ type: "session_diff", data: evt.properties.diff }]), ) + yield* watch(Session.Event.Deleted, (evt) => remove(evt.properties.sessionID)) return cache }), @@ -344,26 +347,4 @@ export namespace ShareNext { Layer.provide(Provider.defaultLayer), Layer.provide(Session.defaultLayer), ) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function init() { - return runPromise((svc) => svc.init()) - } - - export async function url() { - return runPromise((svc) => svc.url()) - } - - export async function request(): Promise { - return runPromise((svc) => svc.request()) - } - - export async function create(sessionID: SessionID) { - return runPromise((svc) => svc.create(sessionID)) - } - - export async function remove(sessionID: SessionID) { - return runPromise((svc) => svc.remove(sessionID)) - } } diff --git a/packages/opencode/src/shell/shell.ts b/packages/opencode/src/shell/shell.ts index df8e8eb7e..0044dda89 100644 --- a/packages/opencode/src/shell/shell.ts +++ b/packages/opencode/src/shell/shell.ts @@ -51,13 +51,13 @@ export namespace Shell { if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell return shell } - return Bun.which(shell) || shell + return which(shell) || shell } function pick() { - const pwsh = Bun.which("pwsh") + const pwsh = which("pwsh.exe") if (pwsh) return pwsh - const powershell = Bun.which("powershell") + const powershell = which("powershell.exe") if (powershell) return powershell } diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts index e10397503..0bc3ee629 100644 --- a/packages/opencode/src/skill/discovery.ts +++ b/packages/opencode/src/skill/discovery.ts @@ -1,5 +1,5 @@ import { NodePath } from "@effect/platform-node" -import { Effect, Layer, Path, Schema, ServiceMap } from "effect" +import { Effect, Layer, Path, Schema, Context } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { withTransientReadRetry } from "@/util/effect-http-client" import { AppFileSystem } from "@/filesystem" @@ -23,7 +23,7 @@ export namespace Discovery { readonly pull: (url: string) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/SkillDiscovery") {} + export class Service extends Context.Service()("@opencode/SkillDiscovery") {} export const layer: Layer.Layer = Layer.effect( diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index cde36dd52..be74c0b34 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -2,7 +2,7 @@ import os from "os" import path from "path" import { pathToFileURL } from "url" import z from "zod" -import { Effect, Layer, ServiceMap } from "effect" +import { Effect, Layer, Context } from "effect" import { NamedError } from "@opencode-ai/util/error" import type { Agent } from "@/agent/agent" import { Bus } from "@/bus" @@ -187,7 +187,7 @@ export namespace Skill { log.info("init", { count: Object.keys(state.skills).length }) }) - export class Service extends ServiceMap.Service()("@opencode/Skill") {} + export class Service extends Context.Service()("@opencode/Skill") {} export const layer = Layer.effect( Service, diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 569c834bf..995e8d3fd 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,4 +1,4 @@ -import { Cause, Duration, Effect, Layer, Schedule, Semaphore, ServiceMap, Stream } from "effect" +import { Cause, Duration, Effect, Layer, Schedule, Semaphore, Context, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { formatPatch, structuredPatch } from "diff" import path from "path" @@ -57,7 +57,7 @@ export namespace Snapshot { readonly diffFull: (from: string, to: string) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/Snapshot") {} + export class Service extends Context.Service()("@opencode/Snapshot") {} export const layer: Layer.Layer< Service, @@ -177,8 +177,39 @@ export namespace Snapshot { const all = Array.from(new Set([...tracked, ...untracked])) if (!all.length) return + // Filter out files that are now gitignored even if previously tracked + // Files may have been tracked before being gitignored, so we need to check + // against the source project's current gitignore rules + // Use --no-index to check purely against patterns (ignoring whether file is tracked) + const checkArgs = [ + ...quote, + "--git-dir", + path.join(state.worktree, ".git"), + "--work-tree", + state.worktree, + "check-ignore", + "--no-index", + "--", + ...all, + ] + const check = yield* git(checkArgs, { cwd: state.directory }) + const ignored = + check.code === 0 ? new Set(check.text.trim().split("\n").filter(Boolean)) : new Set() + const filtered = all.filter((item) => !ignored.has(item)) + + // Remove newly-ignored files from snapshot index to prevent re-adding + if (ignored.size > 0) { + const ignoredFiles = Array.from(ignored) + log.info("removing gitignored files from snapshot", { count: ignoredFiles.length }) + yield* git([...cfg, ...args(["rm", "--cached", "-f", "--", ...ignoredFiles])], { + cwd: state.directory, + }) + } + + if (!filtered.length) return + const large = (yield* Effect.all( - all.map((item) => + filtered.map((item) => fs .stat(path.join(state.directory, item)) .pipe(Effect.catch(() => Effect.void)) @@ -259,14 +290,39 @@ export namespace Snapshot { log.warn("failed to get diff", { hash, exitCode: result.code }) return { hash, files: [] } } + const files = result.text + .trim() + .split("\n") + .map((x) => x.trim()) + .filter(Boolean) + + // Filter out files that are now gitignored + if (files.length > 0) { + const checkArgs = [ + ...quote, + "--git-dir", + path.join(state.worktree, ".git"), + "--work-tree", + state.worktree, + "check-ignore", + "--no-index", + "--", + ...files, + ] + const check = yield* git(checkArgs, { cwd: state.directory }) + if (check.code === 0) { + const ignored = new Set(check.text.trim().split("\n").filter(Boolean)) + const filtered = files.filter((item) => !ignored.has(item)) + return { + hash, + files: filtered.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")), + } + } + } + return { hash, - files: result.text - .trim() - .split("\n") - .map((x) => x.trim()) - .filter(Boolean) - .map((x) => path.join(state.worktree, x).replaceAll("\\", "/")), + files: files.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")), } }), ) @@ -616,6 +672,30 @@ export namespace Snapshot { } satisfies Row, ] }) + + // Filter out files that are now gitignored + if (rows.length > 0) { + const files = rows.map((r) => r.file) + const checkArgs = [ + ...quote, + "--git-dir", + path.join(state.worktree, ".git"), + "--work-tree", + state.worktree, + "check-ignore", + "--no-index", + "--", + ...files, + ] + const check = yield* git(checkArgs, { cwd: state.directory }) + if (check.code === 0) { + const ignored = new Set(check.text.trim().split("\n").filter(Boolean)) + const filtered = rows.filter((r) => !ignored.has(r.file)) + rows.length = 0 + rows.push(...filtered) + } + } + const step = 100 const patch = (file: string, before: string, after: string) => formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 4cb0dbc3e..a7dbf9380 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -2,7 +2,7 @@ import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite" import { migrate } from "drizzle-orm/bun-sqlite/migrator" import { type SQLiteTransaction } from "drizzle-orm/sqlite-core" export * from "drizzle-orm" -import { Context } from "../util/context" +import { LocalContext } from "../util/local-context" import { lazy } from "../util/lazy" import { Global } from "../global" import { Log } from "../util/log" @@ -29,7 +29,7 @@ const log = Log.create({ service: "db" }) export namespace Database { export function getChannelPath() { - if (["latest", "beta"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB) + if (["latest", "beta", "prod"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB) return path.join(Global.Path.data, "opencode.db") const safe = CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-") return path.join(Global.Path.data, `opencode-${safe}.db`) @@ -122,7 +122,7 @@ export namespace Database { export type TxOrDb = Transaction | Client - const ctx = Context.create<{ + const ctx = LocalContext.create<{ tx: TxOrDb effects: (() => void | Promise)[] }>("database") @@ -131,7 +131,7 @@ export namespace Database { try { return callback(ctx.use().tx) } catch (err) { - if (err instanceof Context.NotFound) { + if (err instanceof LocalContext.NotFound) { const effects: (() => void | Promise)[] = [] const result = ctx.provide({ effects, tx: Client() }, () => callback(Client())) for (const effect of effects) effect() @@ -161,7 +161,7 @@ export namespace Database { try { return callback(ctx.use().tx) } catch (err) { - if (err instanceof Context.NotFound) { + if (err instanceof LocalContext.NotFound) { const effects: (() => void | Promise)[] = [] const txCallback = InstanceState.bind((tx: TxOrDb) => ctx.provide({ tx, effects }, () => callback(tx))) const result = Client().transaction(txCallback, { behavior: options?.behavior }) diff --git a/packages/opencode/src/storage/json-migration.ts b/packages/opencode/src/storage/json-migration.ts index 828ce4799..400e3dc9e 100644 --- a/packages/opencode/src/storage/json-migration.ts +++ b/packages/opencode/src/storage/json-migration.ts @@ -1,5 +1,5 @@ -import { Database } from "bun:sqlite" -import { drizzle } from "drizzle-orm/bun-sqlite" +import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite" +import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite" import { Global } from "../global" import { Log } from "../util/log" import { ProjectTable } from "../project/project.sql" @@ -23,7 +23,7 @@ export namespace JsonMigration { progress?: (event: Progress) => void } - export async function run(sqlite: Database, options?: Options) { + export async function run(db: SQLiteBunDatabase | NodeSQLiteDatabase, options?: Options) { const storageDir = path.join(Global.Path.data, "storage") if (!existsSync(storageDir)) { @@ -43,13 +43,13 @@ export namespace JsonMigration { log.info("starting json to sqlite migration", { storageDir }) const start = performance.now() - const db = drizzle({ client: sqlite }) + // const db = drizzle({ client: sqlite }) // Optimize SQLite for bulk inserts - sqlite.exec("PRAGMA journal_mode = WAL") - sqlite.exec("PRAGMA synchronous = OFF") - sqlite.exec("PRAGMA cache_size = 10000") - sqlite.exec("PRAGMA temp_store = MEMORY") + db.run("PRAGMA journal_mode = WAL") + db.run("PRAGMA synchronous = OFF") + db.run("PRAGMA cache_size = 10000") + db.run("PRAGMA temp_store = MEMORY") const stats = { projects: 0, sessions: 0, @@ -146,7 +146,7 @@ export namespace JsonMigration { progress?.({ current, total, label: "starting" }) - sqlite.exec("BEGIN TRANSACTION") + db.run("BEGIN TRANSACTION") // Migrate projects first (no FK deps) // Derive all IDs from file paths, not JSON content @@ -400,7 +400,7 @@ export namespace JsonMigration { log.warn("skipped orphaned session shares", { count: orphans.shares }) } - sqlite.exec("COMMIT") + db.run("COMMIT") log.info("json migration complete", { projects: stats.projects, diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 0d0dce726..a123cd664 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -4,14 +4,17 @@ import { Global } from "../global" import { NamedError } from "@opencode-ai/util/error" import z from "zod" import { AppFileSystem } from "@/filesystem" -import { makeRuntime } from "@/effect/run-service" -import { Effect, Exit, Layer, Option, RcMap, Schema, ServiceMap, TxReentrantLock } from "effect" +import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect" import { Git } from "@/git" export namespace Storage { const log = Log.create({ service: "storage" }) - type Migration = (dir: string, fs: AppFileSystem.Interface) => Effect.Effect + type Migration = ( + dir: string, + fs: AppFileSystem.Interface, + git: Git.Interface, + ) => Effect.Effect export const NotFoundError = NamedError.create( "NotFoundError", @@ -62,7 +65,7 @@ export namespace Storage { readonly list: (prefix: string[]) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/Storage") {} + export class Service extends Context.Service()("@opencode/Storage") {} function file(dir: string, key: string[]) { return path.join(dir, ...key) + ".json" @@ -83,7 +86,7 @@ export namespace Storage { } const MIGRATIONS: Migration[] = [ - Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface) { + Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface, git: Git.Interface) { const project = path.resolve(dir, "../project") if (!(yield* fs.isDir(project))) return const projectDirs = yield* fs.glob("*", { @@ -110,11 +113,9 @@ export namespace Storage { } if (!worktree) continue if (!(yield* fs.isDir(worktree))) continue - const result = yield* Effect.promise(() => - Git.run(["rev-list", "--max-parents=0", "--all"], { - cwd: worktree, - }), - ) + const result = yield* git.run(["rev-list", "--max-parents=0", "--all"], { + cwd: worktree, + }) const [id] = result .text() .split("\n") @@ -220,6 +221,7 @@ export namespace Storage { Service, Effect.gen(function* () { const fs = yield* AppFileSystem.Service + const git = yield* Git.Service const locks = yield* RcMap.make({ lookup: () => TxReentrantLock.make(), idleTimeToLive: 0, @@ -236,7 +238,7 @@ export namespace Storage { for (let i = migration; i < MIGRATIONS.length; i++) { log.info("running migration", { index: i }) const step = MIGRATIONS[i]! - const exit = yield* Effect.exit(step(dir, fs)) + const exit = yield* Effect.exit(step(dir, fs, git)) if (Exit.isFailure(exit)) { log.error("failed to run migration", { index: i, cause: exit.cause }) break @@ -327,27 +329,5 @@ export namespace Storage { }), ) - export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function remove(key: string[]) { - return runPromise((svc) => svc.remove(key)) - } - - export async function read(key: string[]) { - return runPromise((svc) => svc.read(key)) - } - - export async function update(key: string[], fn: (draft: T) => void) { - return runPromise((svc) => svc.update(key, fn)) - } - - export async function write(key: string[], content: T) { - return runPromise((svc) => svc.write(key, content)) - } - - export async function list(prefix: string[]) { - return runPromise((svc) => svc.list(prefix)) - } + export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer)) } diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 270950fd4..a40939191 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -165,7 +165,7 @@ export namespace SyncEvent { // and it validets all the sequence ids // * when loading events from db, apply zod validation to ensure shape - export function replay(event: SerializedEvent, options?: { republish: boolean }) { + export function replay(event: SerializedEvent, options?: { publish: boolean }) { const def = registry.get(event.type) if (!def) { throw new Error(`Unknown event type: ${event.type}`) @@ -189,10 +189,10 @@ export namespace SyncEvent { throw new Error(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`) } - process(def, event, { publish: !!options?.republish }) + process(def, event, { publish: !!options?.publish }) } - export function run(def: Def, data: Event["data"]) { + export function run(def: Def, data: Event["data"], options?: { publish?: boolean }) { const agg = (data as Record)[def.aggregate] // This should never happen: we've enforced it via typescript in // the definition @@ -204,6 +204,8 @@ export namespace SyncEvent { throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`) } + const { publish = true } = options || {} + // Note that this is an "immediate" transaction which is critical. // We need to make sure we can safely read and write with nothing // else changing the data from under us @@ -218,7 +220,7 @@ export namespace SyncEvent { const seq = row?.seq != null ? row.seq + 1 : 0 const event = { id, seq, aggregateID: agg, data } - process(def, event, { publish: true }) + process(def, event, { publish }) }, { behavior: "immediate", diff --git a/packages/opencode/src/sync/schema.ts b/packages/opencode/src/sync/schema.ts index 8e734e598..5cec8b1f7 100644 --- a/packages/opencode/src/sync/schema.ts +++ b/packages/opencode/src/sync/schema.ts @@ -7,8 +7,7 @@ import { withStatics } from "@/util/schema" export const EventID = Schema.String.pipe( Schema.brand("EventID"), withStatics((s) => ({ - make: (id: string) => s.makeUnsafe(id), - ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("event", id)), + ascending: (id?: string) => s.make(Identifier.ascending("event", id)), zod: Identifier.schema("event").pipe(z.custom>()), })), ) diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 30b2e91ac..fd38a9b22 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -1,16 +1,16 @@ import z from "zod" import * as path from "path" -import * as fs from "fs/promises" +import { Effect } from "effect" import { Tool } from "./tool" import { Bus } from "../bus" import { FileWatcher } from "../file/watcher" import { Instance } from "../project/instance" import { Patch } from "../patch" import { createTwoFilesPatch, diffLines } from "diff" -import { assertExternalDirectory } from "./external-directory" +import { assertExternalDirectoryEffect } from "./external-directory" import { trimDiff } from "./edit" import { LSP } from "../lsp" -import { Filesystem } from "../util/filesystem" +import { AppFileSystem } from "../filesystem" import DESCRIPTION from "./apply_patch.txt" import { File } from "../file" import { Format } from "../format" @@ -19,261 +19,268 @@ const PatchParams = z.object({ patchText: z.string().describe("The full patch text that describes all changes to be made"), }) -export const ApplyPatchTool = Tool.define("apply_patch", { - description: DESCRIPTION, - parameters: PatchParams, - async execute(params, ctx) { - if (!params.patchText) { - throw new Error("patchText is required") - } +export const ApplyPatchTool = Tool.define( + "apply_patch", + Effect.gen(function* () { + const lsp = yield* LSP.Service + const afs = yield* AppFileSystem.Service + const format = yield* Format.Service + const bus = yield* Bus.Service - // Parse the patch to get hunks - let hunks: Patch.Hunk[] - try { - const parseResult = Patch.parsePatch(params.patchText) - hunks = parseResult.hunks - } catch (error) { - throw new Error(`apply_patch verification failed: ${error}`) - } - - if (hunks.length === 0) { - const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim() - if (normalized === "*** Begin Patch\n*** End Patch") { - throw new Error("patch rejected: empty patch") + const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer, ctx: Tool.Context) { + if (!params.patchText) { + return yield* Effect.fail(new Error("patchText is required")) } - throw new Error("apply_patch verification failed: no hunks found") - } - // Validate file paths and check permissions - const fileChanges: Array<{ - filePath: string - oldContent: string - newContent: string - type: "add" | "update" | "delete" | "move" - movePath?: string - diff: string - additions: number - deletions: number - }> = [] + // Parse the patch to get hunks + let hunks: Patch.Hunk[] + try { + const parseResult = Patch.parsePatch(params.patchText) + hunks = parseResult.hunks + } catch (error) { + return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`)) + } - let totalDiff = "" - - for (const hunk of hunks) { - const filePath = path.resolve(Instance.directory, hunk.path) - await assertExternalDirectory(ctx, filePath) - - switch (hunk.type) { - case "add": { - const oldContent = "" - const newContent = - hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n` - const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent)) - - let additions = 0 - let deletions = 0 - for (const change of diffLines(oldContent, newContent)) { - if (change.added) additions += change.count || 0 - if (change.removed) deletions += change.count || 0 - } - - fileChanges.push({ - filePath, - oldContent, - newContent, - type: "add", - diff, - additions, - deletions, - }) - - totalDiff += diff + "\n" - break + if (hunks.length === 0) { + const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim() + if (normalized === "*** Begin Patch\n*** End Patch") { + return yield* Effect.fail(new Error("patch rejected: empty patch")) } + return yield* Effect.fail(new Error("apply_patch verification failed: no hunks found")) + } - case "update": { - // Check if file exists for update - const stats = await fs.stat(filePath).catch(() => null) - if (!stats || stats.isDirectory()) { - throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`) + // Validate file paths and check permissions + const fileChanges: Array<{ + filePath: string + oldContent: string + newContent: string + type: "add" | "update" | "delete" | "move" + movePath?: string + diff: string + additions: number + deletions: number + }> = [] + + let totalDiff = "" + + for (const hunk of hunks) { + const filePath = path.resolve(Instance.directory, hunk.path) + yield* assertExternalDirectoryEffect(ctx, filePath) + + switch (hunk.type) { + case "add": { + const oldContent = "" + const newContent = + hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n` + const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent)) + + let additions = 0 + let deletions = 0 + for (const change of diffLines(oldContent, newContent)) { + if (change.added) additions += change.count || 0 + if (change.removed) deletions += change.count || 0 + } + + fileChanges.push({ + filePath, + oldContent, + newContent, + type: "add", + diff, + additions, + deletions, + }) + + totalDiff += diff + "\n" + break } - const oldContent = await fs.readFile(filePath, "utf-8") - let newContent = oldContent + case "update": { + // Check if file exists for update + const stats = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!stats || stats.type === "Directory") { + return yield* Effect.fail( + new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`), + ) + } - // Apply the update chunks to get new content - try { - const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks) - newContent = fileUpdate.content - } catch (error) { - throw new Error(`apply_patch verification failed: ${error}`) + const oldContent = yield* afs.readFileString(filePath) + let newContent = oldContent + + // Apply the update chunks to get new content + try { + const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks) + newContent = fileUpdate.content + } catch (error) { + return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`)) + } + + const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent)) + + let additions = 0 + let deletions = 0 + for (const change of diffLines(oldContent, newContent)) { + if (change.added) additions += change.count || 0 + if (change.removed) deletions += change.count || 0 + } + + const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined + yield* assertExternalDirectoryEffect(ctx, movePath) + + fileChanges.push({ + filePath, + oldContent, + newContent, + type: hunk.move_path ? "move" : "update", + movePath, + diff, + additions, + deletions, + }) + + totalDiff += diff + "\n" + break } - const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent)) + case "delete": { + const contentToDelete = yield* afs + .readFileString(filePath) + .pipe(Effect.catch((error) => Effect.fail(new Error(`apply_patch verification failed: ${error}`)))) + const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, "")) - let additions = 0 - let deletions = 0 - for (const change of diffLines(oldContent, newContent)) { - if (change.added) additions += change.count || 0 - if (change.removed) deletions += change.count || 0 + const deletions = contentToDelete.split("\n").length + + fileChanges.push({ + filePath, + oldContent: contentToDelete, + newContent: "", + type: "delete", + diff: deleteDiff, + additions: 0, + deletions, + }) + + totalDiff += deleteDiff + "\n" + break } - - const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined - await assertExternalDirectory(ctx, movePath) - - fileChanges.push({ - filePath, - oldContent, - newContent, - type: hunk.move_path ? "move" : "update", - movePath, - diff, - additions, - deletions, - }) - - totalDiff += diff + "\n" - break - } - - case "delete": { - const contentToDelete = await fs.readFile(filePath, "utf-8").catch((error) => { - throw new Error(`apply_patch verification failed: ${error}`) - }) - const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, "")) - - const deletions = contentToDelete.split("\n").length - - fileChanges.push({ - filePath, - oldContent: contentToDelete, - newContent: "", - type: "delete", - diff: deleteDiff, - additions: 0, - deletions, - }) - - totalDiff += deleteDiff + "\n" - break } } - } - // Build per-file metadata for UI rendering (used for both permission and result) - const files = fileChanges.map((change) => ({ - filePath: change.filePath, - relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"), - type: change.type, - patch: change.diff, - additions: change.additions, - deletions: change.deletions, - movePath: change.movePath, - })) + // Build per-file metadata for UI rendering (used for both permission and result) + const files = fileChanges.map((change) => ({ + filePath: change.filePath, + relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"), + type: change.type, + patch: change.diff, + additions: change.additions, + deletions: change.deletions, + movePath: change.movePath, + })) - // Check permissions if needed - const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/")) - await ctx.ask({ - permission: "edit", - patterns: relativePaths, - always: ["*"], - metadata: { - filepath: relativePaths.join(", "), - diff: totalDiff, - files, - }, - }) + // Check permissions if needed + const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/")) + yield* ctx.ask({ + permission: "edit", + patterns: relativePaths, + always: ["*"], + metadata: { + filepath: relativePaths.join(", "), + diff: totalDiff, + files, + }, + }) - // Apply the changes - const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = [] + // Apply the changes + const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = [] - for (const change of fileChanges) { - const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath) - switch (change.type) { - case "add": - // Create parent directories (recursive: true is safe on existing/root dirs) - await fs.mkdir(path.dirname(change.filePath), { recursive: true }) - await fs.writeFile(change.filePath, change.newContent, "utf-8") - updates.push({ file: change.filePath, event: "add" }) - break - - case "update": - await fs.writeFile(change.filePath, change.newContent, "utf-8") - updates.push({ file: change.filePath, event: "change" }) - break - - case "move": - if (change.movePath) { + for (const change of fileChanges) { + const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath) + switch (change.type) { + case "add": // Create parent directories (recursive: true is safe on existing/root dirs) - await fs.mkdir(path.dirname(change.movePath), { recursive: true }) - await fs.writeFile(change.movePath, change.newContent, "utf-8") - await fs.unlink(change.filePath) + + yield* afs.writeWithDirs(change.filePath, change.newContent) + updates.push({ file: change.filePath, event: "add" }) + break + + case "update": + yield* afs.writeWithDirs(change.filePath, change.newContent) + updates.push({ file: change.filePath, event: "change" }) + break + + case "move": + if (change.movePath) { + // Create parent directories (recursive: true is safe on existing/root dirs) + + yield* afs.writeWithDirs(change.movePath!, change.newContent) + yield* afs.remove(change.filePath) + updates.push({ file: change.filePath, event: "unlink" }) + updates.push({ file: change.movePath, event: "add" }) + } + break + + case "delete": + yield* afs.remove(change.filePath) updates.push({ file: change.filePath, event: "unlink" }) - updates.push({ file: change.movePath, event: "add" }) - } - break + break + } - case "delete": - await fs.unlink(change.filePath) - updates.push({ file: change.filePath, event: "unlink" }) - break + if (edited) { + yield* format.file(edited) + yield* bus.publish(File.Event.Edited, { file: edited }) + } } - if (edited) { - await Format.file(edited) - Bus.publish(File.Event.Edited, { file: edited }) + // Publish file change events + for (const update of updates) { + yield* bus.publish(FileWatcher.Event.Updated, update) } - } - // Publish file change events - for (const update of updates) { - await Bus.publish(FileWatcher.Event.Updated, update) - } - - // Notify LSP of file changes and collect diagnostics - for (const change of fileChanges) { - if (change.type === "delete") continue - const target = change.movePath ?? change.filePath - await LSP.touchFile(target, true) - } - const diagnostics = await LSP.diagnostics() - - // Generate output summary - const summaryLines = fileChanges.map((change) => { - if (change.type === "add") { - return `A ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}` + // Notify LSP of file changes and collect diagnostics + for (const change of fileChanges) { + if (change.type === "delete") continue + const target = change.movePath ?? change.filePath + yield* lsp.touchFile(target, true) } - if (change.type === "delete") { - return `D ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}` + const diagnostics = yield* lsp.diagnostics() + + // Generate output summary + const summaryLines = fileChanges.map((change) => { + if (change.type === "add") { + return `A ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}` + } + if (change.type === "delete") { + return `D ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}` + } + const target = change.movePath ?? change.filePath + return `M ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}` + }) + let output = `Success. Updated the following files:\n${summaryLines.join("\n")}` + + for (const change of fileChanges) { + if (change.type === "delete") continue + const target = change.movePath ?? change.filePath + const block = LSP.Diagnostic.report(target, diagnostics[AppFileSystem.normalizePath(target)] ?? []) + if (!block) continue + const rel = path.relative(Instance.worktree, target).replaceAll("\\", "/") + output += `\n\nLSP errors detected in ${rel}, please fix:\n${block}` + } + + return { + title: output, + metadata: { + diff: totalDiff, + files, + diagnostics, + }, + output, } - const target = change.movePath ?? change.filePath - return `M ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}` }) - let output = `Success. Updated the following files:\n${summaryLines.join("\n")}` - - // Report LSP errors for changed files - const MAX_DIAGNOSTICS_PER_FILE = 20 - for (const change of fileChanges) { - if (change.type === "delete") continue - const target = change.movePath ?? change.filePath - const normalized = Filesystem.normalizePath(target) - const issues = diagnostics[normalized] ?? [] - const errors = issues.filter((item) => item.severity === 1) - if (errors.length > 0) { - const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) - const suffix = - errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" - output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}, please fix:\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n` - } - } return { - title: output, - metadata: { - diff: totalDiff, - files, - diagnostics, - }, - output, + description: DESCRIPTION, + parameters: PatchParams, + execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), } - }, -}) + }), +) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 365fda329..150cafbd7 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -8,8 +8,7 @@ import { Instance } from "../project/instance" import { lazy } from "@/util/lazy" import { Language, type Node } from "web-tree-sitter" -import { Filesystem } from "@/util/filesystem" -import { Process } from "@/util/process" +import { AppFileSystem } from "@/filesystem" import { fileURLToPath } from "url" import { Flag } from "@/flag/flag" import { Shell } from "@/shell/shell" @@ -17,9 +16,9 @@ import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" import { Truncate } from "./truncate" import { Plugin } from "@/plugin" -import { Cause, Effect, Exit, Stream } from "effect" -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { Effect, Stream } from "effect" +import { ChildProcess } from "effect/unstable/process" +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 @@ -183,34 +182,6 @@ function prefix(text: string) { return text.slice(0, match.index) } -async function cygpath(shell: string, text: string) { - const out = await Process.text([shell, "-lc", 'cygpath -w -- "$1"', "_", text], { nothrow: true }) - if (out.code !== 0) return - const file = out.text.trim() - if (!file) return - return Filesystem.normalizePath(file) -} - -async function resolvePath(text: string, root: string, shell: string) { - if (process.platform === "win32") { - if (Shell.posix(shell) && text.startsWith("/") && Filesystem.windowsPath(text) === text) { - const file = await cygpath(shell, text) - if (file) return file - } - return Filesystem.normalizePath(path.resolve(root, Filesystem.windowsPath(text))) - } - return path.resolve(root, text) -} - -async function argPath(arg: string, cwd: string, ps: boolean, shell: string) { - const text = ps ? expand(arg, cwd, shell) : home(unquote(arg)) - const file = text && prefix(text) - if (!file || dynamic(file, ps)) return - const next = ps ? provider(file) : file - if (!next) return - return resolvePath(next, cwd, shell) -} - function pathArgs(list: Part[], ps: boolean) { if (!ps) { return list @@ -238,55 +209,24 @@ function pathArgs(list: Part[], ps: boolean) { return out } -async function collect(root: Node, cwd: string, ps: boolean, shell: string): Promise { - const scan: Scan = { - dirs: new Set(), - patterns: new Set(), - always: new Set(), - } - - for (const node of commands(root)) { - const command = parts(node) - const tokens = command.map((item) => item.text) - const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0] - - if (cmd && FILES.has(cmd)) { - for (const arg of pathArgs(command, ps)) { - const resolved = await argPath(arg, cwd, ps, shell) - log.info("resolved path", { arg, resolved }) - if (!resolved || Instance.containsPath(resolved)) continue - const dir = (await Filesystem.isDir(resolved)) ? resolved : path.dirname(resolved) - scan.dirs.add(dir) - } - } - - if (tokens.length && (!cmd || !CWD.has(cmd))) { - scan.patterns.add(source(node)) - scan.always.add(BashArity.prefix(tokens).join(" ") + " *") - } - } - - return scan -} - function preview(text: string) { if (text.length <= MAX_METADATA_LENGTH) return text return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..." } -async function parse(command: string, ps: boolean) { - const tree = await parser().then((p) => (ps ? p.ps : p.bash).parse(command)) +const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolean) { + const tree = yield* Effect.promise(() => parser().then((p) => (ps ? p.ps : p.bash).parse(command))) if (!tree) throw new Error("Failed to parse command") return tree.rootNode -} +}) -async function ask(ctx: Tool.Context, scan: Scan) { +const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) { if (scan.dirs.size > 0) { const globs = Array.from(scan.dirs).map((dir) => { - if (process.platform === "win32") return Filesystem.normalizePathPattern(path.join(dir, "*")) + if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*")) return path.join(dir, "*") }) - await ctx.ask({ + yield* ctx.ask({ permission: "external_directory", patterns: globs, always: globs, @@ -295,21 +235,13 @@ async function ask(ctx: Tool.Context, scan: Scan) { } if (scan.patterns.size === 0) return - await ctx.ask({ + yield* ctx.ask({ permission: "bash", patterns: Array.from(scan.patterns), always: Array.from(scan.always), metadata: {}, }) -} - -async function shellEnv(ctx: Tool.Context, cwd: string) { - const extra = await Plugin.trigger("shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} }) - return { - ...process.env, - ...extra.env, - } -} +}) function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { if (process.platform === "win32" && PS.has(name)) { @@ -330,100 +262,6 @@ function cmd(shell: string, name: string, command: string, cwd: string, env: Nod }) } -async function run( - input: { - shell: string - name: string - command: string - cwd: string - env: NodeJS.ProcessEnv - timeout: number - description: string - }, - ctx: Tool.Context, -) { - let output = "" - let expired = false - let aborted = false - - ctx.metadata({ - metadata: { - output: "", - description: input.description, - }, - }) - - const exit = await CrossSpawnSpawner.runPromiseExit((spawner) => - Effect.gen(function* () { - 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) => - Effect.sync(() => { - output += chunk - ctx.metadata({ - metadata: { - output: preview(output), - description: input.description, - }, - }) - }), - ), - ) - - const abort = Effect.callback((resume) => { - if (ctx.abort.aborted) return resume(Effect.void) - const handler = () => resume(Effect.void) - ctx.abort.addEventListener("abort", handler, { once: true }) - return Effect.sync(() => ctx.abort.removeEventListener("abort", handler)) - }) - - const timeout = Effect.sleep(`${input.timeout + 100} millis`) - - const exit = yield* Effect.raceAll([ - handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))), - abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))), - timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))), - ]) - - if (exit.kind === "abort") { - aborted = true - yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) - } - if (exit.kind === "timeout") { - expired = true - yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) - } - - return exit.kind === "exit" ? exit.code : null - }).pipe(Effect.scoped, Effect.orDie), - ) - - let code: number | null = null - if (Exit.isSuccess(exit)) { - code = exit.value - } else if (!Cause.hasInterruptsOnly(exit.cause)) { - throw Cause.squash(exit.cause) - } - - const meta: string[] = [] - if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`) - if (aborted) meta.push("User aborted the command") - if (meta.length > 0) { - output += "\n\n\n" + meta.join("\n") + "\n" - } - - return { - title: input.description, - metadata: { - output: preview(output), - exit: code, - description: input.description, - }, - output, - } -} - const parser = lazy(async () => { const { Parser } = await import("web-tree-sitter") const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, { @@ -452,47 +290,217 @@ const parser = lazy(async () => { }) // TODO: we may wanna rename this tool so it works better on other shells -export const BashTool = Tool.define("bash", 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 }) +export const BashTool = Tool.define( + "bash", + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner + const fs = yield* AppFileSystem.Service + const plugin = yield* Plugin.Service - 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, - async execute(params, ctx) { - const cwd = params.workdir ? await 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 cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) { + const lines = yield* spawner + .lines(ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text])) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + const file = lines[0]?.trim() + if (!file) return + return AppFileSystem.normalizePath(file) + }) + + const resolvePath = Effect.fn("BashTool.resolvePath")(function* (text: string, root: string, shell: string) { + if (process.platform === "win32") { + if (Shell.posix(shell) && text.startsWith("/") && AppFileSystem.windowsPath(text) === text) { + const file = yield* cygpath(shell, text) + if (file) return file + } + return AppFileSystem.normalizePath(path.resolve(root, AppFileSystem.windowsPath(text))) } - const timeout = params.timeout ?? DEFAULT_TIMEOUT - const ps = PS.has(name) - const root = await parse(params.command, ps) - const scan = await collect(root, cwd, ps, shell) - if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) - await ask(ctx, scan) + return path.resolve(root, text) + }) - return run( - { - shell, - name, - command: params.command, - cwd, - env: await shellEnv(ctx, cwd), - timeout, - description: params.description, - }, - ctx, + const argPath = Effect.fn("BashTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) { + const text = ps ? expand(arg, cwd, shell) : home(unquote(arg)) + const file = text && prefix(text) + if (!file || dynamic(file, ps)) return + const next = ps ? provider(file) : file + if (!next) return + return yield* resolvePath(next, cwd, shell) + }) + + const collect = Effect.fn("BashTool.collect")(function* (root: Node, cwd: string, ps: boolean, shell: string) { + const scan: Scan = { + dirs: new Set(), + patterns: new Set(), + always: new Set(), + } + + for (const node of commands(root)) { + const command = parts(node) + const tokens = command.map((item) => item.text) + const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0] + + if (cmd && FILES.has(cmd)) { + for (const arg of pathArgs(command, ps)) { + const resolved = yield* argPath(arg, cwd, ps, shell) + log.info("resolved path", { arg, resolved }) + if (!resolved || Instance.containsPath(resolved)) continue + const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved) + scan.dirs.add(dir) + } + } + + if (tokens.length && (!cmd || !CWD.has(cmd))) { + scan.patterns.add(source(node)) + scan.always.add(BashArity.prefix(tokens).join(" ") + " *") + } + } + + return scan + }) + + const shellEnv = Effect.fn("BashTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) { + const extra = yield* plugin.trigger( + "shell.env", + { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, + { env: {} }, ) - }, - } -}) + return { + ...process.env, + ...extra.env, + } + }) + + const run = Effect.fn("BashTool.run")(function* ( + input: { + shell: string + name: string + command: string + cwd: string + env: NodeJS.ProcessEnv + timeout: number + description: string + }, + ctx: Tool.Context, + ) { + let output = "" + let expired = false + let aborted = false + + yield* ctx.metadata({ + metadata: { + output: "", + description: input.description, + }, + }) + + const code: number | null = yield* Effect.scoped( + Effect.gen(function* () { + 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, + }, + }) + }), + ) + + const abort = Effect.callback((resume) => { + if (ctx.abort.aborted) return resume(Effect.void) + const handler = () => resume(Effect.void) + ctx.abort.addEventListener("abort", handler, { once: true }) + return Effect.sync(() => ctx.abort.removeEventListener("abort", handler)) + }) + + const timeout = Effect.sleep(`${input.timeout + 100} millis`) + + const exit = yield* Effect.raceAll([ + handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))), + abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))), + timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))), + ]) + + if (exit.kind === "abort") { + aborted = true + yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) + } + if (exit.kind === "timeout") { + expired = true + yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) + } + + return exit.kind === "exit" ? exit.code : null + }), + ).pipe(Effect.orDie) + + const meta: string[] = [] + if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`) + if (aborted) meta.push("User aborted the command") + if (meta.length > 0) { + output += "\n\n\n" + meta.join("\n") + "\n" + } + + return { + title: input.description, + metadata: { + output: preview(output), + exit: code, + description: input.description, + }, + output, + } + }) + + 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 { + 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, 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, + ) + }), + } + }) + }), +) diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts index 28dd4eb49..d4d5779bf 100644 --- a/packages/opencode/src/tool/codesearch.ts +++ b/packages/opencode/src/tool/codesearch.ts @@ -1,132 +1,63 @@ import z from "zod" +import { Effect } from "effect" +import { HttpClient } from "effect/unstable/http" import { Tool } from "./tool" +import * as McpExa from "./mcp-exa" import DESCRIPTION from "./codesearch.txt" -import { abortAfterAny } from "../util/abort" -const API_CONFIG = { - BASE_URL: "https://mcp.exa.ai", - ENDPOINTS: { - CONTEXT: "/mcp", - }, -} as const +export const CodeSearchTool = Tool.define( + "codesearch", + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient -interface McpCodeRequest { - jsonrpc: string - id: number - method: string - params: { - name: string - arguments: { - query: string - tokensNum: number - } - } -} + return { + description: DESCRIPTION, + parameters: z.object({ + query: z + .string() + .describe( + "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'", + ), + tokensNum: z + .number() + .min(1000) + .max(50000) + .default(5000) + .describe( + "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.", + ), + }), + execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) => + Effect.gen(function* () { + yield* ctx.ask({ + permission: "codesearch", + patterns: [params.query], + always: ["*"], + metadata: { + query: params.query, + tokensNum: params.tokensNum, + }, + }) -interface McpCodeResponse { - jsonrpc: string - result: { - content: Array<{ - type: string - text: string - }> - } -} + const result = yield* McpExa.call( + http, + "get_code_context_exa", + McpExa.CodeArgs, + { + query: params.query, + tokensNum: params.tokensNum || 5000, + }, + "30 seconds", + ) -export const CodeSearchTool = Tool.define("codesearch", { - description: DESCRIPTION, - parameters: z.object({ - query: z - .string() - .describe( - "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'", - ), - tokensNum: z - .number() - .min(1000) - .max(50000) - .default(5000) - .describe( - "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.", - ), - }), - async execute(params, ctx) { - await ctx.ask({ - permission: "codesearch", - patterns: [params.query], - always: ["*"], - metadata: { - query: params.query, - tokensNum: params.tokensNum, - }, - }) - - const codeRequest: McpCodeRequest = { - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { - name: "get_code_context_exa", - arguments: { - query: params.query, - tokensNum: params.tokensNum || 5000, - }, - }, - } - - const { signal, clearTimeout } = abortAfterAny(30000, ctx.abort) - - try { - const headers: Record = { - accept: "application/json, text/event-stream", - "content-type": "application/json", - } - - const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONTEXT}`, { - method: "POST", - headers, - body: JSON.stringify(codeRequest), - signal, - }) - - clearTimeout() - - if (!response.ok) { - const errorText = await response.text() - throw new Error(`Code search error (${response.status}): ${errorText}`) - } - - const responseText = await response.text() - - // Parse SSE response - const lines = responseText.split("\n") - for (const line of lines) { - if (line.startsWith("data: ")) { - const data: McpCodeResponse = JSON.parse(line.substring(6)) - if (data.result && data.result.content && data.result.content.length > 0) { - return { - output: data.result.content[0].text, - title: `Code search: ${params.query}`, - metadata: {}, - } + return { + output: + result ?? + "No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.", + title: `Code search: ${params.query}`, + metadata: {}, } - } - } - - return { - output: - "No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.", - title: `Code search: ${params.query}`, - metadata: {}, - } - } catch (error) { - clearTimeout() - - if (error instanceof Error && error.name === "AbortError") { - throw new Error("Code search request timed out") - } - - throw error + }).pipe(Effect.orDie), } - }, -}) + }), +) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 9505dd9ea..a835714c6 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -5,6 +5,7 @@ import z from "zod" import * as path from "path" +import { Effect } from "effect" import { Tool } from "./tool" import { LSP } from "../lsp" import { createTwoFilesPatch, diffLines } from "diff" @@ -17,9 +18,8 @@ import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Snapshot } from "@/snapshot" -import { assertExternalDirectory } from "./external-directory" - -const MAX_DIAGNOSTICS_PER_FILE = 20 +import { assertExternalDirectoryEffect } from "./external-directory" +import { AppFileSystem } from "../filesystem" function normalizeLineEndings(text: string): string { return text.replaceAll("\r\n", "\n") @@ -34,136 +34,158 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string { return text.replaceAll("\n", "\r\n") } -export const EditTool = Tool.define("edit", { - description: DESCRIPTION, - parameters: z.object({ - filePath: z.string().describe("The absolute path to the file to modify"), - oldString: z.string().describe("The text to replace"), - newString: z.string().describe("The text to replace it with (must be different from oldString)"), - replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), - }), - async execute(params, ctx) { - if (!params.filePath) { - throw new Error("filePath is required") - } +const Parameters = z.object({ + filePath: z.string().describe("The absolute path to the file to modify"), + oldString: z.string().describe("The text to replace"), + newString: z.string().describe("The text to replace it with (must be different from oldString)"), + replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), +}) - if (params.oldString === params.newString) { - throw new Error("No changes to apply: oldString and newString are identical.") - } - - const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) - await assertExternalDirectory(ctx, filePath) - - let diff = "" - let contentOld = "" - let contentNew = "" - await FileTime.withLock(filePath, async () => { - if (params.oldString === "") { - const existed = await Filesystem.exists(filePath) - contentNew = params.newString - diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) - await ctx.ask({ - permission: "edit", - patterns: [path.relative(Instance.worktree, filePath)], - always: ["*"], - metadata: { - filepath: filePath, - diff, - }, - }) - await Filesystem.write(filePath, params.newString) - await Format.file(filePath) - Bus.publish(File.Event.Edited, { file: filePath }) - await Bus.publish(FileWatcher.Event.Updated, { - file: filePath, - event: existed ? "change" : "add", - }) - await FileTime.read(ctx.sessionID, filePath) - return - } - - const stats = Filesystem.stat(filePath) - if (!stats) throw new Error(`File ${filePath} not found`) - if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`) - await FileTime.assert(ctx.sessionID, filePath) - contentOld = await Filesystem.readText(filePath) - - const ending = detectLineEnding(contentOld) - const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending) - const next = convertToLineEnding(normalizeLineEndings(params.newString), ending) - - contentNew = replace(contentOld, old, next, params.replaceAll) - - diff = trimDiff( - createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)), - ) - await ctx.ask({ - permission: "edit", - patterns: [path.relative(Instance.worktree, filePath)], - always: ["*"], - metadata: { - filepath: filePath, - diff, - }, - }) - - await Filesystem.write(filePath, contentNew) - await Format.file(filePath) - Bus.publish(File.Event.Edited, { file: filePath }) - await Bus.publish(FileWatcher.Event.Updated, { - file: filePath, - event: "change", - }) - contentNew = await Filesystem.readText(filePath) - diff = trimDiff( - createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)), - ) - await FileTime.read(ctx.sessionID, filePath) - }) - - const filediff: Snapshot.FileDiff = { - file: filePath, - patch: diff, - additions: 0, - deletions: 0, - } - for (const change of diffLines(contentOld, contentNew)) { - if (change.added) filediff.additions += change.count || 0 - if (change.removed) filediff.deletions += change.count || 0 - } - - ctx.metadata({ - metadata: { - diff, - filediff, - diagnostics: {}, - }, - }) - - let output = "Edit applied successfully." - await LSP.touchFile(filePath, true) - const diagnostics = await LSP.diagnostics() - const normalizedFilePath = Filesystem.normalizePath(filePath) - const issues = diagnostics[normalizedFilePath] ?? [] - const errors = issues.filter((item) => item.severity === 1) - if (errors.length > 0) { - const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) - const suffix = - errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" - output += `\n\nLSP errors detected in this file, please fix:\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n` - } +export const EditTool = Tool.define( + "edit", + Effect.gen(function* () { + const lsp = yield* LSP.Service + const filetime = yield* FileTime.Service + const afs = yield* AppFileSystem.Service + const format = yield* Format.Service + const bus = yield* Bus.Service return { - metadata: { - diagnostics, - diff, - filediff, - }, - title: `${path.relative(Instance.worktree, filePath)}`, - output, + description: DESCRIPTION, + parameters: Parameters, + execute: (params: z.infer, ctx: Tool.Context) => + Effect.gen(function* () { + if (!params.filePath) { + throw new Error("filePath is required") + } + + if (params.oldString === params.newString) { + throw new Error("No changes to apply: oldString and newString are identical.") + } + + const filePath = path.isAbsolute(params.filePath) + ? params.filePath + : path.join(Instance.directory, params.filePath) + yield* assertExternalDirectoryEffect(ctx, filePath) + + let diff = "" + let contentOld = "" + let contentNew = "" + yield* filetime.withLock(filePath, () => + Effect.gen(function* () { + if (params.oldString === "") { + const existed = yield* afs.existsSafe(filePath) + contentNew = params.newString + diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) + yield* ctx.ask({ + permission: "edit", + patterns: [path.relative(Instance.worktree, filePath)], + always: ["*"], + metadata: { + filepath: filePath, + diff, + }, + }) + yield* afs.writeWithDirs(filePath, params.newString) + yield* format.file(filePath) + yield* bus.publish(File.Event.Edited, { file: filePath }) + yield* bus.publish(FileWatcher.Event.Updated, { + file: filePath, + event: existed ? "change" : "add", + }) + yield* filetime.read(ctx.sessionID, filePath) + return + } + + const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!info) throw new Error(`File ${filePath} not found`) + if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`) + yield* filetime.assert(ctx.sessionID, filePath) + contentOld = yield* afs.readFileString(filePath) + + const ending = detectLineEnding(contentOld) + const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending) + const next = convertToLineEnding(normalizeLineEndings(params.newString), ending) + + contentNew = replace(contentOld, old, next, params.replaceAll) + + diff = trimDiff( + createTwoFilesPatch( + filePath, + filePath, + normalizeLineEndings(contentOld), + normalizeLineEndings(contentNew), + ), + ) + yield* ctx.ask({ + permission: "edit", + patterns: [path.relative(Instance.worktree, filePath)], + always: ["*"], + metadata: { + filepath: filePath, + diff, + }, + }) + + yield* afs.writeWithDirs(filePath, contentNew) + yield* format.file(filePath) + yield* bus.publish(File.Event.Edited, { file: filePath }) + yield* bus.publish(FileWatcher.Event.Updated, { + file: filePath, + event: "change", + }) + contentNew = yield* afs.readFileString(filePath) + diff = trimDiff( + createTwoFilesPatch( + filePath, + filePath, + normalizeLineEndings(contentOld), + normalizeLineEndings(contentNew), + ), + ) + yield* filetime.read(ctx.sessionID, filePath) + }).pipe(Effect.orDie), + ) + + const filediff: Snapshot.FileDiff = { + file: filePath, + patch: diff, + additions: 0, + deletions: 0, + } + for (const change of diffLines(contentOld, contentNew)) { + if (change.added) filediff.additions += change.count || 0 + if (change.removed) filediff.deletions += change.count || 0 + } + + yield* ctx.metadata({ + metadata: { + diff, + filediff, + diagnostics: {}, + }, + }) + + let output = "Edit applied successfully." + yield* lsp.touchFile(filePath, true) + const diagnostics = yield* lsp.diagnostics() + const normalizedFilePath = Filesystem.normalizePath(filePath) + const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? []) + if (block) output += `\n\nLSP errors detected in this file, please fix:\n${block}` + + return { + metadata: { + diagnostics, + diff, + filediff, + }, + title: `${path.relative(Instance.worktree, filePath)}`, + output, + } + }), } - }, -}) + }), +) export type Replacer = (content: string, find: string) => Generator diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index f11455cf5..ff8854649 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -1,5 +1,6 @@ import path from "path" import { Effect } from "effect" +import { EffectLogger } from "@/effect/logger" import type { Tool } from "./tool" import { Instance } from "../project/instance" import { AppFileSystem } from "../filesystem" @@ -11,7 +12,11 @@ type Options = { kind?: Kind } -export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) { +export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirectory")(function* ( + ctx: Tool.Context, + target?: string, + options?: Options, +) { if (!target) return if (options?.bypass) return @@ -26,7 +31,7 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string ? AppFileSystem.normalizePathPattern(path.join(dir, "*")) : path.join(dir, "*").replaceAll("\\", "/") - await ctx.ask({ + yield* ctx.ask({ permission: "external_directory", patterns: [glob], always: [glob], @@ -35,12 +40,8 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string parentDir: dir, }, }) -} - -export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirectory")(function* ( - ctx: Tool.Context, - target?: string, - options?: Options, -) { - yield* Effect.promise(() => assertExternalDirectory(ctx, target, options)) }) + +export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) { + return Effect.runPromise(assertExternalDirectoryEffect(ctx, target, options).pipe(Effect.provide(EffectLogger.layer))) +} diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index a2611246c..a3ff5aef7 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -1,78 +1,94 @@ import z from "zod" import path from "path" +import { Effect, Option } from "effect" +import * as Stream from "effect/Stream" import { Tool } from "./tool" -import { Filesystem } from "../util/filesystem" import DESCRIPTION from "./glob.txt" import { Ripgrep } from "../file/ripgrep" import { Instance } from "../project/instance" -import { assertExternalDirectory } from "./external-directory" +import { assertExternalDirectoryEffect } from "./external-directory" +import { AppFileSystem } from "../filesystem" -export const GlobTool = Tool.define("glob", { - description: DESCRIPTION, - parameters: z.object({ - pattern: z.string().describe("The glob pattern to match files against"), - path: z - .string() - .optional() - .describe( - `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`, - ), - }), - async execute(params, ctx) { - await ctx.ask({ - permission: "glob", - patterns: [params.pattern], - always: ["*"], - metadata: { - pattern: params.pattern, - path: params.path, - }, - }) - - let search = params.path ?? Instance.directory - search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search) - await assertExternalDirectory(ctx, search, { kind: "directory" }) - - const limit = 100 - const files = [] - let truncated = false - for await (const file of Ripgrep.files({ - cwd: search, - glob: [params.pattern], - signal: ctx.abort, - })) { - if (files.length >= limit) { - truncated = true - break - } - const full = path.resolve(search, file) - const stats = Filesystem.stat(full)?.mtime.getTime() ?? 0 - files.push({ - path: full, - mtime: stats, - }) - } - files.sort((a, b) => b.mtime - a.mtime) - - const output = [] - if (files.length === 0) output.push("No files found") - if (files.length > 0) { - output.push(...files.map((f) => f.path)) - if (truncated) { - output.push("") - output.push( - `(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`, - ) - } - } +export const GlobTool = Tool.define( + "glob", + Effect.gen(function* () { + const rg = yield* Ripgrep.Service + const fs = yield* AppFileSystem.Service return { - title: path.relative(Instance.worktree, search), - metadata: { - count: files.length, - truncated, - }, - output: output.join("\n"), + description: DESCRIPTION, + parameters: z.object({ + pattern: z.string().describe("The glob pattern to match files against"), + path: z + .string() + .optional() + .describe( + `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`, + ), + }), + execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) => + Effect.gen(function* () { + yield* ctx.ask({ + permission: "glob", + patterns: [params.pattern], + always: ["*"], + metadata: { + pattern: params.pattern, + path: params.path, + }, + }) + + let search = params.path ?? Instance.directory + search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search) + yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" }) + + const limit = 100 + let truncated = false + const files = yield* rg.files({ cwd: search, glob: [params.pattern] }).pipe( + Stream.mapEffect((file) => + Effect.gen(function* () { + const full = path.resolve(search, file) + const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined))) + const mtime = + info?.mtime.pipe( + Option.map((d) => d.getTime()), + Option.getOrElse(() => 0), + ) ?? 0 + return { path: full, mtime } + }), + ), + Stream.take(limit + 1), + Stream.runCollect, + Effect.map((chunk) => [...chunk]), + ) + + if (files.length > limit) { + truncated = true + files.length = limit + } + files.sort((a, b) => b.mtime - a.mtime) + + const output = [] + if (files.length === 0) output.push("No files found") + if (files.length > 0) { + output.push(...files.map((f) => f.path)) + if (truncated) { + output.push("") + output.push( + `(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`, + ) + } + } + + return { + title: path.relative(Instance.worktree, search), + metadata: { + count: files.length, + truncated, + }, + output: output.join("\n"), + } + }).pipe(Effect.orDie), } - }, -}) + }), +) diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 82e7ac166..b5ae6c350 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -1,156 +1,175 @@ import z from "zod" -import { text } from "node:stream/consumers" +import { Effect } from "effect" +import * as Stream from "effect/Stream" import { Tool } from "./tool" import { Filesystem } from "../util/filesystem" import { Ripgrep } from "../file/ripgrep" -import { Process } from "../util/process" +import { ChildProcess } from "effect/unstable/process" +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import DESCRIPTION from "./grep.txt" import { Instance } from "../project/instance" import path from "path" -import { assertExternalDirectory } from "./external-directory" +import { assertExternalDirectoryEffect } from "./external-directory" const MAX_LINE_LENGTH = 2000 -export const GrepTool = Tool.define("grep", { - description: DESCRIPTION, - parameters: z.object({ - pattern: z.string().describe("The regex pattern to search for in file contents"), - path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."), - include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'), - }), - async execute(params, ctx) { - if (!params.pattern) { - throw new Error("pattern is required") - } - - await ctx.ask({ - permission: "grep", - patterns: [params.pattern], - always: ["*"], - metadata: { - pattern: params.pattern, - path: params.path, - include: params.include, - }, - }) - - let searchPath = params.path ?? Instance.directory - searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath) - await assertExternalDirectory(ctx, searchPath, { kind: "directory" }) - - const rgPath = await Ripgrep.filepath() - const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern] - if (params.include) { - args.push("--glob", params.include) - } - args.push(searchPath) - - const proc = Process.spawn([rgPath, ...args], { - stdout: "pipe", - stderr: "pipe", - abort: ctx.abort, - }) - - if (!proc.stdout || !proc.stderr) { - throw new Error("Process output not available") - } - - const output = await text(proc.stdout) - const errorOutput = await text(proc.stderr) - const exitCode = await proc.exited - - // Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches) - // With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc. - // Only fail if exit code is 2 AND no output was produced - if (exitCode === 1 || (exitCode === 2 && !output.trim())) { - return { - title: params.pattern, - metadata: { matches: 0, truncated: false }, - output: "No files found", - } - } - - if (exitCode !== 0 && exitCode !== 2) { - throw new Error(`ripgrep failed: ${errorOutput}`) - } - - const hasErrors = exitCode === 2 - - // Handle both Unix (\n) and Windows (\r\n) line endings - const lines = output.trim().split(/\r?\n/) - const matches = [] - - for (const line of lines) { - if (!line) continue - - const [filePath, lineNumStr, ...lineTextParts] = line.split("|") - if (!filePath || !lineNumStr || lineTextParts.length === 0) continue - - const lineNum = parseInt(lineNumStr, 10) - const lineText = lineTextParts.join("|") - - const stats = Filesystem.stat(filePath) - if (!stats) continue - - matches.push({ - path: filePath, - modTime: stats.mtime.getTime(), - lineNum, - lineText, - }) - } - - matches.sort((a, b) => b.modTime - a.modTime) - - const limit = 100 - const truncated = matches.length > limit - const finalMatches = truncated ? matches.slice(0, limit) : matches - - if (finalMatches.length === 0) { - return { - title: params.pattern, - metadata: { matches: 0, truncated: false }, - output: "No files found", - } - } - - const totalMatches = matches.length - const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`] - - let currentFile = "" - for (const match of finalMatches) { - if (currentFile !== match.path) { - if (currentFile !== "") { - outputLines.push("") - } - currentFile = match.path - outputLines.push(`${match.path}:`) - } - const truncatedLineText = - match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText - outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`) - } - - if (truncated) { - outputLines.push("") - outputLines.push( - `(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`, - ) - } - - if (hasErrors) { - outputLines.push("") - outputLines.push("(Some paths were inaccessible and skipped)") - } +export const GrepTool = Tool.define( + "grep", + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner return { - title: params.pattern, - metadata: { - matches: totalMatches, - truncated, - }, - output: outputLines.join("\n"), + description: DESCRIPTION, + parameters: z.object({ + pattern: z.string().describe("The regex pattern to search for in file contents"), + path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."), + include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'), + }), + execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) => + Effect.gen(function* () { + if (!params.pattern) { + throw new Error("pattern is required") + } + + yield* ctx.ask({ + permission: "grep", + patterns: [params.pattern], + always: ["*"], + metadata: { + pattern: params.pattern, + path: params.path, + include: params.include, + }, + }) + + let searchPath = params.path ?? Instance.directory + searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath) + yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" }) + + const rgPath = yield* Effect.promise(() => Ripgrep.filepath()) + const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern] + if (params.include) { + args.push("--glob", params.include) + } + args.push(searchPath) + + const result = yield* Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawner.spawn( + ChildProcess.make(rgPath, args, { + stdin: "ignore", + }), + ) + + const [output, errorOutput] = yield* Effect.all( + [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ) + + const exitCode = yield* handle.exitCode + + return { output, errorOutput, exitCode } + }), + ) + + const { output, errorOutput, exitCode } = result + + // Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches) + // With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc. + // Only fail if exit code is 2 AND no output was produced + if (exitCode === 1 || (exitCode === 2 && !output.trim())) { + return { + title: params.pattern, + metadata: { matches: 0, truncated: false }, + output: "No files found", + } + } + + if (exitCode !== 0 && exitCode !== 2) { + throw new Error(`ripgrep failed: ${errorOutput}`) + } + + const hasErrors = exitCode === 2 + + // Handle both Unix (\n) and Windows (\r\n) line endings + const lines = output.trim().split(/\r?\n/) + const matches = [] + + for (const line of lines) { + if (!line) continue + + const [filePath, lineNumStr, ...lineTextParts] = line.split("|") + if (!filePath || !lineNumStr || lineTextParts.length === 0) continue + + const lineNum = parseInt(lineNumStr, 10) + const lineText = lineTextParts.join("|") + + const stats = Filesystem.stat(filePath) + if (!stats) continue + + matches.push({ + path: filePath, + modTime: stats.mtime.getTime(), + lineNum, + lineText, + }) + } + + matches.sort((a, b) => b.modTime - a.modTime) + + const limit = 100 + const truncated = matches.length > limit + const finalMatches = truncated ? matches.slice(0, limit) : matches + + if (finalMatches.length === 0) { + return { + title: params.pattern, + metadata: { matches: 0, truncated: false }, + output: "No files found", + } + } + + const totalMatches = matches.length + const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`] + + let currentFile = "" + for (const match of finalMatches) { + if (currentFile !== match.path) { + if (currentFile !== "") { + outputLines.push("") + } + currentFile = match.path + outputLines.push(`${match.path}:`) + } + const truncatedLineText = + match.lineText.length > MAX_LINE_LENGTH + ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." + : match.lineText + outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`) + } + + if (truncated) { + outputLines.push("") + outputLines.push( + `(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`, + ) + } + + if (hasErrors) { + outputLines.push("") + outputLines.push("(Some paths were inaccessible and skipped)") + } + + return { + title: params.pattern, + metadata: { + matches: totalMatches, + truncated, + }, + output: outputLines.join("\n"), + } + }).pipe(Effect.orDie), } - }, -}) + }), +) diff --git a/packages/opencode/src/tool/invalid.ts b/packages/opencode/src/tool/invalid.ts index 728e9c89f..b9794ed5f 100644 --- a/packages/opencode/src/tool/invalid.ts +++ b/packages/opencode/src/tool/invalid.ts @@ -1,17 +1,20 @@ import z from "zod" +import { Effect } from "effect" import { Tool } from "./tool" -export const InvalidTool = Tool.define("invalid", { - description: "Do not use", - parameters: z.object({ - tool: z.string(), - error: z.string(), +export const InvalidTool = Tool.define( + "invalid", + Effect.succeed({ + description: "Do not use", + parameters: z.object({ + tool: z.string(), + error: z.string(), + }), + execute: (params: { tool: string; error: string }) => + Effect.succeed({ + title: "Invalid Tool", + output: `The arguments provided to the tool are invalid: ${params.error}`, + metadata: {}, + }), }), - async execute(params) { - return { - title: "Invalid Tool", - output: `The arguments provided to the tool are invalid: ${params.error}`, - metadata: {}, - } - }, -}) +) diff --git a/packages/opencode/src/tool/ls.ts b/packages/opencode/src/tool/ls.ts index b848e969b..600a5532a 100644 --- a/packages/opencode/src/tool/ls.ts +++ b/packages/opencode/src/tool/ls.ts @@ -1,10 +1,12 @@ import z from "zod" +import { Effect } from "effect" +import * as Stream from "effect/Stream" import { Tool } from "./tool" import * as path from "path" import DESCRIPTION from "./ls.txt" import { Instance } from "../project/instance" import { Ripgrep } from "../file/ripgrep" -import { assertExternalDirectory } from "./external-directory" +import { assertExternalDirectoryEffect } from "./external-directory" export const IGNORE_PATTERNS = [ "node_modules/", @@ -35,87 +37,98 @@ export const IGNORE_PATTERNS = [ const LIMIT = 100 -export const ListTool = Tool.define("list", { - description: DESCRIPTION, - parameters: z.object({ - path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(), - ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(), - }), - async execute(params, ctx) { - const searchPath = path.resolve(Instance.directory, params.path || ".") - await assertExternalDirectory(ctx, searchPath, { kind: "directory" }) - - await ctx.ask({ - permission: "list", - patterns: [searchPath], - always: ["*"], - metadata: { - path: searchPath, - }, - }) - - const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || []) - const files = [] - for await (const file of Ripgrep.files({ cwd: searchPath, glob: ignoreGlobs, signal: ctx.abort })) { - files.push(file) - if (files.length >= LIMIT) break - } - - // Build directory structure - const dirs = new Set() - const filesByDir = new Map() - - for (const file of files) { - const dir = path.dirname(file) - const parts = dir === "." ? [] : dir.split("/") - - // Add all parent directories - for (let i = 0; i <= parts.length; i++) { - const dirPath = i === 0 ? "." : parts.slice(0, i).join("/") - dirs.add(dirPath) - } - - // Add file to its directory - if (!filesByDir.has(dir)) filesByDir.set(dir, []) - filesByDir.get(dir)!.push(path.basename(file)) - } - - function renderDir(dirPath: string, depth: number): string { - const indent = " ".repeat(depth) - let output = "" - - if (depth > 0) { - output += `${indent}${path.basename(dirPath)}/\n` - } - - const childIndent = " ".repeat(depth + 1) - const children = Array.from(dirs) - .filter((d) => path.dirname(d) === dirPath && d !== dirPath) - .sort() - - // Render subdirectories first - for (const child of children) { - output += renderDir(child, depth + 1) - } - - // Render files - const files = filesByDir.get(dirPath) || [] - for (const file of files.sort()) { - output += `${childIndent}${file}\n` - } - - return output - } - - const output = `${searchPath}/\n` + renderDir(".", 0) +export const ListTool = Tool.define( + "list", + Effect.gen(function* () { + const rg = yield* Ripgrep.Service return { - title: path.relative(Instance.worktree, searchPath), - metadata: { - count: files.length, - truncated: files.length >= LIMIT, - }, - output, + description: DESCRIPTION, + parameters: z.object({ + path: z + .string() + .describe("The absolute path to the directory to list (must be absolute, not relative)") + .optional(), + ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(), + }), + execute: (params: { path?: string; ignore?: string[] }, ctx: Tool.Context) => + Effect.gen(function* () { + const searchPath = path.resolve(Instance.directory, params.path || ".") + yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" }) + + yield* ctx.ask({ + permission: "list", + patterns: [searchPath], + always: ["*"], + metadata: { + path: searchPath, + }, + }) + + const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || []) + const files = yield* rg.files({ cwd: searchPath, glob: ignoreGlobs }).pipe( + Stream.take(LIMIT), + Stream.runCollect, + Effect.map((chunk) => [...chunk]), + ) + + // Build directory structure + const dirs = new Set() + const filesByDir = new Map() + + for (const file of files) { + const dir = path.dirname(file) + const parts = dir === "." ? [] : dir.split("/") + + // Add all parent directories + for (let i = 0; i <= parts.length; i++) { + const dirPath = i === 0 ? "." : parts.slice(0, i).join("/") + dirs.add(dirPath) + } + + // Add file to its directory + if (!filesByDir.has(dir)) filesByDir.set(dir, []) + filesByDir.get(dir)!.push(path.basename(file)) + } + + function renderDir(dirPath: string, depth: number): string { + const indent = " ".repeat(depth) + let output = "" + + if (depth > 0) { + output += `${indent}${path.basename(dirPath)}/\n` + } + + const childIndent = " ".repeat(depth + 1) + const children = Array.from(dirs) + .filter((d) => path.dirname(d) === dirPath && d !== dirPath) + .sort() + + // Render subdirectories first + for (const child of children) { + output += renderDir(child, depth + 1) + } + + // Render files + const files = filesByDir.get(dirPath) || [] + for (const file of files.sort()) { + output += `${childIndent}${file}\n` + } + + return output + } + + const output = `${searchPath}/\n` + renderDir(".", 0) + + return { + title: path.relative(Instance.worktree, searchPath), + metadata: { + count: files.length, + truncated: files.length >= LIMIT, + }, + output, + } + }).pipe(Effect.orDie), } - }, -}) + }), +) diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index 52aef0f9e..c5a5d6f81 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -1,12 +1,13 @@ import z from "zod" +import { Effect } from "effect" import { Tool } from "./tool" import path from "path" import { LSP } from "../lsp" import DESCRIPTION from "./lsp.txt" import { Instance } from "../project/instance" import { pathToFileURL } from "url" -import { assertExternalDirectory } from "./external-directory" -import { Filesystem } from "../util/filesystem" +import { assertExternalDirectoryEffect } from "./external-directory" +import { AppFileSystem } from "../filesystem" const operations = [ "goToDefinition", @@ -20,78 +21,71 @@ const operations = [ "outgoingCalls", ] as const -export const LspTool = Tool.define("lsp", { - description: DESCRIPTION, - parameters: z.object({ - operation: z.enum(operations).describe("The LSP operation to perform"), - filePath: z.string().describe("The absolute or relative path to the file"), - line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"), - character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"), - }), - execute: async (args, ctx) => { - const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath) - await assertExternalDirectory(ctx, file) - - await ctx.ask({ - permission: "lsp", - patterns: ["*"], - always: ["*"], - metadata: {}, - }) - const uri = pathToFileURL(file).href - const position = { - file, - line: args.line - 1, - character: args.character - 1, - } - - const relPath = path.relative(Instance.worktree, file) - const title = `${args.operation} ${relPath}:${args.line}:${args.character}` - - const exists = await Filesystem.exists(file) - if (!exists) { - throw new Error(`File not found: ${file}`) - } - - const available = await LSP.hasClients(file) - if (!available) { - throw new Error("No LSP server available for this file type.") - } - - await LSP.touchFile(file, true) - - const result: unknown[] = await (async () => { - switch (args.operation) { - case "goToDefinition": - return LSP.definition(position) - case "findReferences": - return LSP.references(position) - case "hover": - return LSP.hover(position) - case "documentSymbol": - return LSP.documentSymbol(uri) - case "workspaceSymbol": - return LSP.workspaceSymbol("") - case "goToImplementation": - return LSP.implementation(position) - case "prepareCallHierarchy": - return LSP.prepareCallHierarchy(position) - case "incomingCalls": - return LSP.incomingCalls(position) - case "outgoingCalls": - return LSP.outgoingCalls(position) - } - })() - - const output = (() => { - if (result.length === 0) return `No results found for ${args.operation}` - return JSON.stringify(result, null, 2) - })() +export const LspTool = Tool.define( + "lsp", + Effect.gen(function* () { + const lsp = yield* LSP.Service + const fs = yield* AppFileSystem.Service return { - title, - metadata: { result }, - output, + description: DESCRIPTION, + parameters: z.object({ + operation: z.enum(operations).describe("The LSP operation to perform"), + filePath: z.string().describe("The absolute or relative path to the file"), + line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"), + character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"), + }), + execute: ( + args: { operation: (typeof operations)[number]; filePath: string; line: number; character: number }, + ctx: Tool.Context, + ) => + Effect.gen(function* () { + const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath) + yield* assertExternalDirectoryEffect(ctx, file) + yield* ctx.ask({ permission: "lsp", patterns: ["*"], always: ["*"], metadata: {} }) + + const uri = pathToFileURL(file).href + const position = { file, line: args.line - 1, character: args.character - 1 } + const relPath = path.relative(Instance.worktree, file) + const title = `${args.operation} ${relPath}:${args.line}:${args.character}` + + const exists = yield* fs.existsSafe(file) + if (!exists) throw new Error(`File not found: ${file}`) + + const available = yield* lsp.hasClients(file) + if (!available) throw new Error("No LSP server available for this file type.") + + yield* lsp.touchFile(file, true) + + const result: unknown[] = yield* (() => { + switch (args.operation) { + case "goToDefinition": + return lsp.definition(position) + case "findReferences": + return lsp.references(position) + case "hover": + return lsp.hover(position) + case "documentSymbol": + return lsp.documentSymbol(uri) + case "workspaceSymbol": + return lsp.workspaceSymbol("") + case "goToImplementation": + return lsp.implementation(position) + case "prepareCallHierarchy": + return lsp.prepareCallHierarchy(position) + case "incomingCalls": + return lsp.incomingCalls(position) + case "outgoingCalls": + return lsp.outgoingCalls(position) + } + })() + + return { + title, + metadata: { result }, + output: result.length === 0 ? `No results found for ${args.operation}` : JSON.stringify(result, null, 2), + } + }), } - }, -}) + }), +) diff --git a/packages/opencode/src/tool/mcp-exa.ts b/packages/opencode/src/tool/mcp-exa.ts new file mode 100644 index 000000000..638d68c24 --- /dev/null +++ b/packages/opencode/src/tool/mcp-exa.ts @@ -0,0 +1,76 @@ +import { Duration, Effect, Schema } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" + +const URL = "https://mcp.exa.ai/mcp" + +const McpResult = Schema.Struct({ + result: Schema.Struct({ + content: Schema.Array( + Schema.Struct({ + type: Schema.String, + text: Schema.String, + }), + ), + }), +}) + +const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult)) + +const parseSse = Effect.fn("McpExa.parseSse")(function* (body: string) { + for (const line of body.split("\n")) { + if (!line.startsWith("data: ")) continue + const data = yield* decode(line.substring(6)) + if (data.result.content[0]?.text) return data.result.content[0].text + } + return undefined +}) + +export const SearchArgs = Schema.Struct({ + query: Schema.String, + type: Schema.String, + numResults: Schema.Number, + livecrawl: Schema.String, + contextMaxCharacters: Schema.optional(Schema.Number), +}) + +export const CodeArgs = Schema.Struct({ + query: Schema.String, + tokensNum: Schema.Number, +}) + +const McpRequest = (args: Schema.Struct) => + Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + id: Schema.Literal(1), + method: Schema.Literal("tools/call"), + params: Schema.Struct({ + name: Schema.String, + arguments: args, + }), + }) + +export const call = ( + http: HttpClient.HttpClient, + tool: string, + args: Schema.Struct, + value: Schema.Struct.Type, + timeout: Duration.Input, +) => + Effect.gen(function* () { + const request = yield* HttpClientRequest.post(URL).pipe( + HttpClientRequest.accept("application/json, text/event-stream"), + HttpClientRequest.schemaBodyJson(McpRequest(args))({ + jsonrpc: "2.0" as const, + id: 1 as const, + method: "tools/call" as const, + params: { name: tool, arguments: value }, + }), + ) + const response = yield* HttpClient.filterStatusOk(http) + .execute(request) + .pipe( + Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error(`${tool} request timed out`)) }), + ) + const body = yield* response.text + return yield* parseSse(body) + }) diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts index 7f562f473..449df3343 100644 --- a/packages/opencode/src/tool/multiedit.ts +++ b/packages/opencode/src/tool/multiedit.ts @@ -1,46 +1,61 @@ import z from "zod" +import { Effect } from "effect" import { Tool } from "./tool" import { EditTool } from "./edit" import DESCRIPTION from "./multiedit.txt" import path from "path" import { Instance } from "../project/instance" -export const MultiEditTool = Tool.define("multiedit", { - description: DESCRIPTION, - parameters: z.object({ - filePath: z.string().describe("The absolute path to the file to modify"), - edits: z - .array( - z.object({ - filePath: z.string().describe("The absolute path to the file to modify"), - oldString: z.string().describe("The text to replace"), - newString: z.string().describe("The text to replace it with (must be different from oldString)"), - replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), - }), - ) - .describe("Array of edit operations to perform sequentially on the file"), - }), - async execute(params, ctx) { - const tool = await EditTool.init() - const results = [] - for (const [, edit] of params.edits.entries()) { - const result = await tool.execute( - { - filePath: params.filePath, - oldString: edit.oldString, - newString: edit.newString, - replaceAll: edit.replaceAll, - }, - ctx, - ) - results.push(result) - } +export const MultiEditTool = Tool.define( + "multiedit", + Effect.gen(function* () { + const editInfo = yield* EditTool + const edit = yield* editInfo.init() + return { - title: path.relative(Instance.worktree, params.filePath), - metadata: { - results: results.map((r) => r.metadata), - }, - output: results.at(-1)!.output, + description: DESCRIPTION, + parameters: z.object({ + filePath: z.string().describe("The absolute path to the file to modify"), + edits: z + .array( + z.object({ + filePath: z.string().describe("The absolute path to the file to modify"), + oldString: z.string().describe("The text to replace"), + newString: z.string().describe("The text to replace it with (must be different from oldString)"), + replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), + }), + ) + .describe("Array of edit operations to perform sequentially on the file"), + }), + execute: ( + params: { + filePath: string + edits: Array<{ filePath: string; oldString: string; newString: string; replaceAll?: boolean }> + }, + ctx: Tool.Context, + ) => + Effect.gen(function* () { + const results = [] + for (const [, entry] of params.edits.entries()) { + const result = yield* edit.execute( + { + filePath: params.filePath, + oldString: entry.oldString, + newString: entry.newString, + replaceAll: entry.replaceAll, + }, + ctx, + ) + results.push(result) + } + return { + title: path.relative(Instance.worktree, params.filePath), + metadata: { + results: results.map((r) => r.metadata), + }, + output: results.at(-1)!.output, + } + }), } - }, -}) + }), +) diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index e91bc3faa..1613821fe 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -1,5 +1,6 @@ import z from "zod" import path from "path" +import { Effect } from "effect" import { Tool } from "./tool" import { Question } from "../question" import { Session } from "../session" @@ -9,123 +10,71 @@ import { Instance } from "../project/instance" import { type SessionID, MessageID, PartID } from "../session/schema" import EXIT_DESCRIPTION from "./plan-exit.txt" -async function getLastModel(sessionID: SessionID) { - for await (const item of MessageV2.stream(sessionID)) { +function getLastModel(sessionID: SessionID) { + for (const item of MessageV2.stream(sessionID)) { if (item.info.role === "user" && item.info.model) return item.info.model } - return Provider.defaultModel() + return undefined } -export const PlanExitTool = Tool.define("plan_exit", { - description: EXIT_DESCRIPTION, - parameters: z.object({}), - async execute(_params, ctx) { - const session = await Session.get(ctx.sessionID) - const plan = path.relative(Instance.worktree, Session.plan(session)) - const answers = await Question.ask({ - sessionID: ctx.sessionID, - questions: [ - { - question: `Plan at ${plan} is complete. Would you like to switch to the build agent and start implementing?`, - header: "Build Agent", - custom: false, - options: [ - { label: "Yes", description: "Switch to build agent and start implementing the plan" }, - { label: "No", description: "Stay with plan agent to continue refining the plan" }, - ], - }, - ], - tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, - }) - - const answer = answers[0]?.[0] - if (answer === "No") throw new Question.RejectedError() - - const model = await getLastModel(ctx.sessionID) - - const userMsg: MessageV2.User = { - id: MessageID.ascending(), - sessionID: ctx.sessionID, - role: "user", - time: { - created: Date.now(), - }, - agent: "build", - model, - } - await Session.updateMessage(userMsg) - await Session.updatePart({ - id: PartID.ascending(), - messageID: userMsg.id, - sessionID: ctx.sessionID, - type: "text", - text: `The plan at ${plan} has been approved, you can now edit files. Execute the plan`, - synthetic: true, - } satisfies MessageV2.TextPart) +export const PlanExitTool = Tool.define( + "plan_exit", + Effect.gen(function* () { + const session = yield* Session.Service + const question = yield* Question.Service + const provider = yield* Provider.Service return { - title: "Switching to build agent", - output: "User approved switching to build agent. Wait for further instructions.", - metadata: {}, + description: EXIT_DESCRIPTION, + parameters: z.object({}), + execute: (_params: {}, ctx: Tool.Context) => + Effect.gen(function* () { + const info = yield* session.get(ctx.sessionID) + const plan = path.relative(Instance.worktree, Session.plan(info)) + const answers = yield* question.ask({ + sessionID: ctx.sessionID, + questions: [ + { + question: `Plan at ${plan} is complete. Would you like to switch to the build agent and start implementing?`, + header: "Build Agent", + custom: false, + options: [ + { label: "Yes", description: "Switch to build agent and start implementing the plan" }, + { label: "No", description: "Stay with plan agent to continue refining the plan" }, + ], + }, + ], + tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, + }) + + if (answers[0]?.[0] === "No") yield* new Question.RejectedError() + + const model = getLastModel(ctx.sessionID) ?? (yield* provider.defaultModel()) + + const msg: MessageV2.User = { + id: MessageID.ascending(), + sessionID: ctx.sessionID, + role: "user", + time: { created: Date.now() }, + agent: "build", + model, + } + yield* session.updateMessage(msg) + yield* session.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID: ctx.sessionID, + type: "text", + text: `The plan at ${plan} has been approved, you can now edit files. Execute the plan`, + synthetic: true, + } satisfies MessageV2.TextPart) + + return { + title: "Switching to build agent", + output: "User approved switching to build agent. Wait for further instructions.", + metadata: {}, + } + }).pipe(Effect.orDie), } - }, -}) - -/* -export const PlanEnterTool = Tool.define("plan_enter", { - description: ENTER_DESCRIPTION, - parameters: z.object({}), - async execute(_params, ctx) { - const session = await Session.get(ctx.sessionID) - const plan = path.relative(Instance.worktree, Session.plan(session)) - - const answers = await Question.ask({ - sessionID: ctx.sessionID, - questions: [ - { - question: `Would you like to switch to the plan agent and create a plan saved to ${plan}?`, - header: "Plan Mode", - custom: false, - options: [ - { label: "Yes", description: "Switch to plan agent for research and planning" }, - { label: "No", description: "Stay with build agent to continue making changes" }, - ], - }, - ], - tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, - }) - - const answer = answers[0]?.[0] - - if (answer === "No") throw new Question.RejectedError() - - const model = await getLastModel(ctx.sessionID) - - const userMsg: MessageV2.User = { - id: MessageID.ascending(), - sessionID: ctx.sessionID, - role: "user", - time: { - created: Date.now(), - }, - agent: "plan", - model, - } - await Session.updateMessage(userMsg) - await Session.updatePart({ - id: PartID.ascending(), - messageID: userMsg.id, - sessionID: ctx.sessionID, - type: "text", - text: "User has requested to enter plan mode. Switch to plan mode and begin planning.", - synthetic: true, - } satisfies MessageV2.TextPart) - - return { - title: "Switching to plan agent", - output: `User confirmed to switch to plan mode. A new message has been created to switch you to plan mode. The plan file will be at ${plan}. Begin planning.`, - metadata: {}, - } - }, -}) -*/ + }), +) diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index 23c9b35c8..8cfa700a5 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -12,7 +12,7 @@ type Metadata = { answers: Question.Answer[] } -export const QuestionTool = Tool.defineEffect( +export const QuestionTool = Tool.define( "question", Effect.gen(function* () { const question = yield* Question.Service @@ -20,27 +20,26 @@ export const QuestionTool = Tool.defineEffect, ctx: Tool.Context) { - const answers = await question - .ask({ + execute: (params: z.infer, ctx: Tool.Context) => + Effect.gen(function* () { + const answers = yield* question.ask({ sessionID: ctx.sessionID, questions: params.questions, tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, }) - .pipe(Effect.runPromise) - const formatted = params.questions - .map((q, i) => `"${q.question}"="${answers[i]?.length ? answers[i].join(", ") : "Unanswered"}"`) - .join(", ") + const formatted = params.questions + .map((q, i) => `"${q.question}"="${answers[i]?.length ? answers[i].join(", ") : "Unanswered"}"`) + .join(", ") - return { - title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`, - output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`, - metadata: { - answers, - }, - } - }, + return { + title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`, + output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`, + metadata: { + answers, + }, + } + }).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 0b44c7ad5..501a8c97e 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -25,7 +25,7 @@ const parameters = z.object({ limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(), }) -export const ReadTool = Tool.defineEffect( +export const ReadTool = Tool.define( "read", Effect.gen(function* () { const fs = yield* AppFileSystem.Service @@ -67,9 +67,7 @@ export const ReadTool = Tool.defineEffect( if (item.type === "directory") return item.name + "/" if (item.type !== "symlink") return item.name - const target = yield* fs - .stat(path.join(filepath, item.name)) - .pipe(Effect.catch(() => Effect.succeed(undefined))) + const target = yield* fs.stat(path.join(filepath, item.name)).pipe(Effect.catch(() => Effect.void)) if (target?.type === "Directory") return item.name + "/" return item.name }), @@ -108,14 +106,12 @@ export const ReadTool = Tool.defineEffect( kind: stat?.type === "Directory" ? "directory" : "file", }) - yield* Effect.promise(() => - ctx.ask({ - permission: "read", - patterns: [filepath], - always: ["*"], - metadata: {}, - }), - ) + yield* ctx.ask({ + permission: "read", + patterns: [filepath], + always: ["*"], + metadata: {}, + }) if (!stat) return yield* miss(filepath) @@ -220,9 +216,7 @@ export const ReadTool = Tool.defineEffect( return { description: DESCRIPTION, parameters, - async execute(params: z.infer, ctx) { - return Effect.runPromise(run(params, ctx).pipe(Effect.orDie)) - }, + execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 72911051e..afb19a468 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,21 +1,23 @@ import { PlanExitTool } from "./plan" +import { Session } from "../session" import { QuestionTool } from "./question" import { BashTool } from "./bash" import { EditTool } from "./edit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" import { ReadTool } from "./read" -import { TaskDescription, TaskTool } from "./task" +import { TaskTool } from "./task" import { TodoWriteTool } from "./todo" import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" -import { SkillDescription, SkillTool } from "./skill" +import { SkillTool } from "./skill" import { Tool } from "./tool" import { Config } from "../config/config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" import z from "zod" import { Plugin } from "../plugin" +import { Provider } from "../provider/provider" import { ProviderID, type ModelID } from "../provider/schema" import { WebSearchTool } from "./websearch" import { CodeSearchTool } from "./codesearch" @@ -27,7 +29,12 @@ import { ApplyPatchTool } from "./apply_patch" import { Glob } from "../util/glob" import path from "path" import { pathToFileURL } from "url" -import { Effect, Layer, ServiceMap } from "effect" +import { Effect, Layer, Context } from "effect" +import { FetchHttpClient, HttpClient } from "effect/unstable/http" +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { Ripgrep } from "../file/ripgrep" +import { Format } from "../format" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { Env } from "../env" @@ -37,28 +44,36 @@ import { LSP } from "../lsp" import { FileTime } from "../file/time" import { Instruction } from "../session/instruction" import { AppFileSystem } from "../filesystem" +import { Bus } from "../bus" import { Agent } from "../agent/agent" +import { Skill } from "../skill" +import { Permission } from "@/permission" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) + type TaskDef = Tool.InferDef + type ReadDef = Tool.InferDef + type State = { custom: Tool.Def[] builtin: Tool.Def[] + task: TaskDef + read: ReadDef } export interface Interface { readonly ids: () => Effect.Effect readonly all: () => Effect.Effect + readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }> readonly tools: (model: { providerID: ProviderID modelID: ModelID agent: Agent.Info }) => Effect.Effect - readonly fromID: (id: string) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/ToolRegistry") {} + export class Service extends Context.Service()("@opencode/ToolRegistry") {} export const layer: Layer.Layer< Service, @@ -67,18 +82,46 @@ export namespace ToolRegistry { | Plugin.Service | Question.Service | Todo.Service + | Agent.Service + | Skill.Service + | Session.Service + | Provider.Service | LSP.Service | FileTime.Service | Instruction.Service | AppFileSystem.Service + | Bus.Service + | HttpClient.HttpClient + | ChildProcessSpawner + | Ripgrep.Service + | Format.Service + | Truncate.Service > = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service const plugin = yield* Plugin.Service + const agents = yield* Agent.Service + const skill = yield* Skill.Service + const truncate = yield* Truncate.Service - const build = (tool: T | Effect.Effect) => - Effect.isEffect(tool) ? tool.pipe(Effect.flatMap(Tool.init)) : Tool.init(tool) + const invalid = yield* InvalidTool + const task = yield* TaskTool + const read = yield* ReadTool + const question = yield* QuestionTool + const todo = yield* TodoWriteTool + const lsptool = yield* LspTool + const plan = yield* PlanExitTool + const webfetch = yield* WebFetchTool + const websearch = yield* WebSearchTool + const bash = yield* BashTool + const codesearch = yield* CodeSearchTool + const globtool = yield* GlobTool + const writetool = yield* WriteTool + const edit = yield* EditTool + const greptool = yield* GrepTool + const patchtool = yield* ApplyPatchTool + const skilltool = yield* SkillTool const state = yield* InstanceState.make( Effect.fn("ToolRegistry.state")(function* (ctx) { @@ -89,23 +132,26 @@ export namespace ToolRegistry { id, parameters: z.object(def.args), description: def.description, - execute: async (args, toolCtx) => { - const pluginCtx = { - ...toolCtx, - directory: ctx.directory, - worktree: ctx.worktree, - } as unknown as PluginToolContext - const result = await def.execute(args as any, pluginCtx) - const out = await Truncate.output(result, {}, await Agent.get(toolCtx.agent)) - return { - title: "", - output: out.truncated ? out.content : result, - metadata: { - truncated: out.truncated, - outputPath: out.truncated ? out.outputPath : undefined, - }, - } - }, + execute: (args, toolCtx) => + Effect.gen(function* () { + const pluginCtx: PluginToolContext = { + ...toolCtx, + ask: (req) => toolCtx.ask(req), + directory: ctx.directory, + worktree: ctx.worktree, + } + const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx)) + const agent = yield* Effect.promise(() => Agent.get(toolCtx.agent)) + const out = yield* truncate.output(result, {}, agent) + return { + title: "", + output: out.truncated ? out.content : result, + metadata: { + truncated: out.truncated, + outputPath: out.truncated ? out.outputPath : undefined, + }, + } + }), } } @@ -132,34 +178,52 @@ export namespace ToolRegistry { } const cfg = yield* config.get() - const question = + const questionEnabled = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL + const tool = yield* Effect.all({ + invalid: Tool.init(invalid), + bash: Tool.init(bash), + read: Tool.init(read), + glob: Tool.init(globtool), + grep: Tool.init(greptool), + edit: Tool.init(edit), + write: Tool.init(writetool), + task: Tool.init(task), + fetch: Tool.init(webfetch), + todo: Tool.init(todo), + search: Tool.init(websearch), + code: Tool.init(codesearch), + skill: Tool.init(skilltool), + patch: Tool.init(patchtool), + question: Tool.init(question), + lsp: Tool.init(lsptool), + plan: Tool.init(plan), + }) + return { custom, - builtin: yield* Effect.forEach( - [ - InvalidTool, - BashTool, - ReadTool, - GlobTool, - GrepTool, - EditTool, - WriteTool, - TaskTool, - WebFetchTool, - TodoWriteTool, - WebSearchTool, - CodeSearchTool, - SkillTool, - ApplyPatchTool, - ...(question ? [QuestionTool] : []), - ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), - ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []), - ], - build, - { concurrency: "unbounded" }, - ), + builtin: [ + tool.invalid, + ...(questionEnabled ? [tool.question] : []), + tool.bash, + tool.read, + tool.glob, + tool.grep, + tool.edit, + tool.write, + tool.task, + tool.fetch, + tool.todo, + tool.search, + tool.code, + tool.skill, + tool.patch, + ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []), + ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [tool.plan] : []), + ], + task: tool.task, + read: tool.read, } }), ) @@ -169,17 +233,44 @@ export namespace ToolRegistry { return [...s.builtin, ...s.custom] as Tool.Def[] }) - const fromID: Interface["fromID"] = Effect.fn("ToolRegistry.fromID")(function* (id: string) { - const tools = yield* all() - const match = tools.find((tool) => tool.id === id) - if (!match) return yield* Effect.die(`Tool not found: ${id}`) - return match - }) - const ids: Interface["ids"] = Effect.fn("ToolRegistry.ids")(function* () { return (yield* all()).map((tool) => tool.id) }) + const describeSkill = Effect.fn("ToolRegistry.describeSkill")(function* (agent: Agent.Info) { + const list = yield* skill.available(agent) + if (list.length === 0) return "No skills are currently available." + return [ + "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 `` 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") + }) + + const describeTask = Effect.fn("ToolRegistry.describeTask")(function* (agent: Agent.Info) { + const items = (yield* agents.list()).filter((item) => item.mode !== "primary") + const filtered = items.filter( + (item) => Permission.evaluate("task", item.name, agent.permission).action !== "deny", + ) + const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name)) + const description = list + .map( + (item) => + `- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`, + ) + .join("\n") + return ["Available agent types and the tools they have access to:", description].join("\n") + }) + const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { const filtered = (yield* all()).filter((tool) => { if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) { @@ -208,9 +299,8 @@ export namespace ToolRegistry { id: tool.id, description: [ output.description, - // TODO: remove this hack - tool.id === TaskTool.id ? yield* TaskDescription(input.agent) : undefined, - tool.id === SkillTool.id ? yield* SkillDescription(input.agent) : undefined, + tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined, + tool.id === SkillTool.id ? yield* describeSkill(input.agent) : undefined, ] .filter(Boolean) .join("\n"), @@ -223,22 +313,35 @@ export namespace ToolRegistry { ) }) - return Service.of({ ids, tools, all, fromID }) + const named: Interface["named"] = Effect.fn("ToolRegistry.named")(function* () { + const s = yield* InstanceState.get(state) + return { task: s.task, read: s.read } + }) + + return Service.of({ ids, all, named, tools }) }), ) - export const defaultLayer = Layer.unwrap( - Effect.sync(() => - layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(Question.defaultLayer), - Layer.provide(Todo.defaultLayer), - Layer.provide(LSP.defaultLayer), - Layer.provide(FileTime.defaultLayer), - Layer.provide(Instruction.defaultLayer), - Layer.provide(AppFileSystem.defaultLayer), - ), + export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(Question.defaultLayer), + Layer.provide(Todo.defaultLayer), + Layer.provide(Skill.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(LSP.defaultLayer), + Layer.provide(FileTime.defaultLayer), + Layer.provide(Instruction.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(Format.defaultLayer), + Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Truncate.defaultLayer), ), ) diff --git a/packages/opencode/src/tool/schema.ts b/packages/opencode/src/tool/schema.ts index 93f0f9a71..823bb0aed 100644 --- a/packages/opencode/src/tool/schema.ts +++ b/packages/opencode/src/tool/schema.ts @@ -10,8 +10,7 @@ export type ToolID = typeof toolIdSchema.Type export const ToolID = toolIdSchema.pipe( withStatics((schema: typeof toolIdSchema) => ({ - make: (id: string) => schema.makeUnsafe(id), - ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("tool", id)), + ascending: (id?: string) => schema.make(Identifier.ascending("tool", id)), zod: Identifier.schema("tool").pipe(z.custom()), })), ) diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 276f3931d..14adaf231 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -1,120 +1,100 @@ -import { Effect } from "effect" import path from "path" import { pathToFileURL } from "url" import z from "zod" +import { Effect } from "effect" +import { EffectLogger } from "@/effect/logger" +import * as Stream from "effect/Stream" import { Tool } from "./tool" import { Skill } from "../skill" import { Ripgrep } from "../file/ripgrep" -import { iife } from "@/util/iife" const Parameters = z.object({ name: z.string().describe("The name of the skill from available_skills"), }) -export const SkillTool = Tool.define("skill", async () => { - const list = await Skill.available() - - 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 `` 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, - async execute(params: z.infer, ctx) { - const skill = await Skill.get(params.name) - - if (!skill) { - const available = await Skill.all().then((x) => x.map((skill) => skill.name).join(", ")) - throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) - } - - await ctx.ask({ - permission: "skill", - patterns: [params.name], - always: [params.name], - metadata: {}, - }) - - const dir = path.dirname(skill.location) - const base = pathToFileURL(dir).href - - const limit = 10 - const files = await iife(async () => { - const arr = [] - for await (const file of Ripgrep.files({ - cwd: dir, - follow: false, - hidden: true, - signal: ctx.abort, - })) { - if (file.includes("SKILL.md")) { - continue - } - arr.push(path.resolve(dir, file)) - if (arr.length >= limit) { - break - } - } - return arr - }).then((f) => f.map((file) => `${file}`).join("\n")) - - return { - title: `Loaded skill: ${skill.name}`, - output: [ - ``, - `# Skill: ${skill.name}`, - "", - skill.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.", - "", - "", - files, - "", - "", - ].join("\n"), - metadata: { - name: skill.name, - dir, - }, - } - }, - } -}) - -export const SkillDescription: Tool.DynamicDescription = (agent) => +export const SkillTool = Tool.define( + "skill", Effect.gen(function* () { - const list = yield* Effect.promise(() => Skill.available(agent)) - if (list.length === 0) return "No skills are currently available." - return [ - "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 `` 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") - }) + 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.", + "", + "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 `` 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, 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}`).join("\n")), + ) + + return { + title: `Loaded skill: ${info.name}`, + output: [ + ``, + `# 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.", + "", + "", + files, + "", + "", + ].join("\n"), + metadata: { + name: info.name, + dir, + }, + } + }).pipe(Effect.orDie), + } + }) + }), +) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 07e779f5b..ce99ab299 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -5,42 +5,45 @@ import { Session } from "../session" import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" -import { SessionPrompt } from "../session/prompt" -import { iife } from "@/util/iife" -import { defer } from "@/util/defer" +import type { SessionPrompt } from "../session/prompt" import { Config } from "../config/config" -import { Permission } from "@/permission" import { Effect } from "effect" +import { Log } from "@/util/log" -export const TaskTool = Tool.define("task", async () => { - const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary")) - const list = agents.toSorted((a, b) => a.name.localeCompare(b.name)) - const agentList = list - .map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`) - .join("\n") - const description = [`Available agent types and the tools they have access to:`, agentList].join("\n") +export interface TaskPromptOps { + cancel(sessionID: SessionID): void + resolvePromptParts(template: string): Effect.Effect + prompt(input: SessionPrompt.PromptInput): Effect.Effect +} - return { - description, - parameters: z.object({ - description: z.string().describe("A short (3-5 words) description of the task"), - prompt: z.string().describe("The task for the agent to perform"), - subagent_type: z.string().describe("The type of specialized agent to use for this task"), - task_id: z - .string() - .describe( - "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)", - ) - .optional(), - command: z.string().describe("The command that triggered this task").optional(), - }), - async execute(params, ctx) { - const config = await Config.get() +const id = "task" + +const parameters = z.object({ + description: z.string().describe("A short (3-5 words) description of the task"), + prompt: z.string().describe("The task for the agent to perform"), + subagent_type: z.string().describe("The type of specialized agent to use for this task"), + task_id: z + .string() + .describe( + "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)", + ) + .optional(), + command: z.string().describe("The command that triggered this task").optional(), +}) + +export const TaskTool = Tool.define( + id, + 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, ctx: Tool.Context) { + const cfg = yield* config.get() - // Skip permission check when user explicitly invoked via @ or command subtask if (!ctx.extra?.bypassAgentCheck) { - await ctx.ask({ - permission: "task", + yield* ctx.ask({ + permission: id, patterns: [params.subagent_type], always: ["*"], metadata: { @@ -50,23 +53,25 @@ export const TaskTool = Tool.define("task", async () => { }) } - const agent = await Agent.get(params.subagent_type) - if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) + const next = yield* agent.get(params.subagent_type) + if (!next) { + return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)) + } - const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task") - const hasTodoWritePermission = agent.permission.some((rule) => rule.permission === "todowrite") + const canTask = next.permission.some((rule) => rule.permission === id) + const canTodo = next.permission.some((rule) => rule.permission === "todowrite") - const session = await iife(async () => { - if (params.task_id) { - const found = await Session.get(SessionID.make(params.task_id)).catch(() => {}) - if (found) return found - } - - return await Session.create({ + const taskID = params.task_id + const session = taskID + ? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined))) + : undefined + const nextSession = + session ?? + (yield* sessions.create({ parentID: ctx.sessionID, - title: params.description + ` (@${agent.name} subagent)`, + title: params.description + ` (@${next.name} subagent)`, permission: [ - ...(hasTodoWritePermission + ...(canTodo ? [] : [ { @@ -75,95 +80,97 @@ export const TaskTool = Tool.define("task", async () => { action: "deny" as const, }, ]), - ...(hasTaskPermission + ...(canTask ? [] : [ { - permission: "task" as const, + permission: id, pattern: "*" as const, action: "deny" as const, }, ]), - ...(config.experimental?.primary_tools?.map((t) => ({ + ...(cfg.experimental?.primary_tools?.map((item) => ({ pattern: "*", action: "allow" as const, - permission: t, + permission: item, })) ?? []), ], - }) - }) - const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }) - if (msg.info.role !== "assistant") throw new Error("Not an assistant message") + })) - const model = agent.model ?? { + 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")) + + const model = next.model ?? { modelID: msg.info.modelID, providerID: msg.info.providerID, } - ctx.metadata({ + yield* ctx.metadata({ title: params.description, metadata: { - sessionId: session.id, + sessionId: nextSession.id, model, }, }) + const ops = ctx.extra?.promptOps as TaskPromptOps + if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra")) + const messageID = MessageID.ascending() function cancel() { - SessionPrompt.cancel(session.id) + ops.cancel(nextSession.id) } - ctx.abort.addEventListener("abort", cancel) - using _ = defer(() => ctx.abort.removeEventListener("abort", cancel)) - const promptParts = await SessionPrompt.resolvePromptParts(params.prompt) - const result = await SessionPrompt.prompt({ - messageID, - sessionID: session.id, - model: { - modelID: model.modelID, - providerID: model.providerID, - }, - agent: agent.name, - tools: { - ...(hasTodoWritePermission ? {} : { todowrite: false }), - ...(hasTaskPermission ? {} : { task: false }), - ...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])), - }, - parts: promptParts, - }) + return yield* Effect.acquireUseRelease( + Effect.sync(() => { + ctx.abort.addEventListener("abort", cancel) + }), + () => + Effect.gen(function* () { + const parts = yield* ops.resolvePromptParts(params.prompt) + const result = yield* ops.prompt({ + messageID, + sessionID: nextSession.id, + model: { + modelID: model.modelID, + providerID: model.providerID, + }, + agent: next.name, + tools: { + ...(canTodo ? {} : { todowrite: false }), + ...(canTask ? {} : { task: false }), + ...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])), + }, + parts, + }) - const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" + return { + title: params.description, + metadata: { + sessionId: nextSession.id, + model, + }, + output: [ + `task_id: ${nextSession.id} (for resuming to continue this task if needed)`, + "", + "", + result.parts.findLast((item) => item.type === "text")?.text ?? "", + "", + ].join("\n"), + } + }), + () => + Effect.sync(() => { + ctx.abort.removeEventListener("abort", cancel) + }), + ) + }) - const output = [ - `task_id: ${session.id} (for resuming to continue this task if needed)`, - "", - "", - text, - "", - ].join("\n") - - return { - title: params.description, - metadata: { - sessionId: session.id, - model, - }, - output, - } - }, - } -}) - -export const TaskDescription: Tool.DynamicDescription = (agent) => - Effect.gen(function* () { - const agents = yield* Effect.promise(() => Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))) - const accessibleAgents = agents.filter( - (a) => Permission.evaluate("task", a.name, agent.permission).action !== "deny", - ) - const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name)) - const description = list - .map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`) - .join("\n") - return [`Available agent types and the tools they have access to:`, description].join("\n") - }) + return { + description: DESCRIPTION, + parameters, + execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts index 92318164c..253bcfa32 100644 --- a/packages/opencode/src/tool/todo.ts +++ b/packages/opencode/src/tool/todo.ts @@ -12,7 +12,7 @@ type Metadata = { todos: Todo.Info[] } -export const TodoWriteTool = Tool.defineEffect( +export const TodoWriteTool = Tool.define( "todowrite", Effect.gen(function* () { const todo = yield* Todo.Service @@ -20,29 +20,28 @@ export const TodoWriteTool = Tool.defineEffect, ctx: Tool.Context) { - await ctx.ask({ - permission: "todowrite", - patterns: ["*"], - always: ["*"], - metadata: {}, - }) + execute: (params: z.infer, ctx: Tool.Context) => + Effect.gen(function* () { + yield* ctx.ask({ + permission: "todowrite", + patterns: ["*"], + always: ["*"], + metadata: {}, + }) - await todo - .update({ + yield* todo.update({ sessionID: ctx.sessionID, todos: params.todos, }) - .pipe(Effect.runPromise) - return { - title: `${params.todos.filter((x) => x.status !== "completed").length} todos`, - output: JSON.stringify(params.todos, null, 2), - metadata: { - todos: params.todos, - }, - } - }, + return { + title: `${params.todos.filter((x) => x.status !== "completed").length} todos`, + output: JSON.stringify(params.todos, null, 2), + metadata: { + todos: params.todos, + }, + } + }), } satisfies Tool.DefWithoutID }), ) diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 6d129f427..49dd2b060 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -22,23 +22,22 @@ export namespace Tool { callID?: string extra?: { [key: string]: any } messages: MessageV2.WithParts[] - metadata(input: { title?: string; metadata?: M }): void - ask(input: Omit): Promise + metadata(input: { title?: string; metadata?: M }): Effect.Effect + ask(input: Omit): Effect.Effect + } + + export interface ExecuteResult { + title: string + metadata: M + output: string + attachments?: Omit[] } export interface Def { id: string description: string parameters: Parameters - execute( - args: z.infer, - ctx: Context, - ): Promise<{ - title: string - metadata: M - output: string - attachments?: Omit[] - }> + execute(args: z.infer, ctx: Context): Effect.Effect> formatValidationError?(error: z.ZodError): string } export type DefWithoutID = Omit< @@ -48,9 +47,13 @@ export namespace Tool { export interface Info { id: string - init: () => Promise> + init: () => Effect.Effect> } + type Init = + | DefWithoutID + | (() => Effect.Effect>) + export type InferParameters = T extends Info ? z.infer

@@ -60,64 +63,75 @@ export namespace Tool { export type InferMetadata = T extends Info ? M : T extends Effect.Effect, any, any> ? M : never + export type InferDef = + T extends Info + ? Def + : T extends Effect.Effect, any, any> + ? Def + : never + function wrap( id: string, - init: (() => Promise>) | DefWithoutID, + init: Init, + truncate: Truncate.Interface, + agents: Agent.Interface, ) { - return async () => { - const toolInfo = init instanceof Function ? await init() : { ...init } - const execute = toolInfo.execute - toolInfo.execute = async (args, ctx) => { - try { - toolInfo.parameters.parse(args) - } catch (error) { - if (error instanceof z.ZodError && toolInfo.formatValidationError) { - throw new Error(toolInfo.formatValidationError(error), { cause: error }) - } - throw 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 = await execute(args, ctx) - if (result.metadata.truncated !== undefined) { - return result - } - const truncated = await Truncate.output(result.output, {}, await Agent.get(ctx.agent)) - return { - ...result, - output: truncated.content, - metadata: { - ...result.metadata, - truncated: truncated.truncated, - ...(truncated.truncated && { outputPath: truncated.outputPath }), - }, - } - } - return toolInfo - } + 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 + }) } - export function define( - id: string, - init: (() => Promise>) | DefWithoutID, - ): Info { - return { - id, - init: wrap(id, init), - } + export function define( + id: ID, + init: Effect.Effect, never, R>, + ): Effect.Effect, never, R | Truncate.Service | Agent.Service> & { 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) } + }), + { id }, + ) } - export function defineEffect( - id: string, - init: Effect.Effect<(() => Promise>) | DefWithoutID, never, R>, - ): Effect.Effect, never, R> { - return Effect.map(init, (next) => ({ id, init: wrap(id, next) })) - } - - export function init(info: Info): Effect.Effect { + export function init

(info: Info): Effect.Effect> { return Effect.gen(function* () { - const init = yield* Effect.promise(() => info.init()) + const init = yield* info.init() return { ...init, id: info.id, diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index 5cddacefc..fa9e0bcab 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -1,8 +1,7 @@ import { NodePath } from "@effect/platform-node" -import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect" +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" @@ -41,7 +40,7 @@ export namespace Truncate { readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/Truncate") {} + export class Service extends Context.Service()("@opencode/Truncate") {} export const layer = Layer.effect( Service, @@ -135,10 +134,4 @@ export namespace Truncate { ) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer)) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise { - return runPromise((s) => s.output(text, options, agent)) - } } diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index 559afd677..9339038b0 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -1,170 +1,161 @@ import z from "zod" +import { Effect } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Tool } from "./tool" import TurndownService from "turndown" import DESCRIPTION from "./webfetch.txt" -import { abortAfterAny } from "../util/abort" -import { iife } from "@/util/iife" const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds const MAX_TIMEOUT = 120 * 1000 // 2 minutes -export const WebFetchTool = Tool.define("webfetch", { - description: DESCRIPTION, - parameters: z.object({ - url: z.string().describe("The URL to fetch content from"), - format: z - .enum(["text", "markdown", "html"]) - .default("markdown") - .describe("The format to return the content in (text, markdown, or html). Defaults to markdown."), - timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(), - }), - async execute(params, ctx) { - // Validate URL - if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) { - throw new Error("URL must start with http:// or https://") - } - - await ctx.ask({ - permission: "webfetch", - patterns: [params.url], - always: ["*"], - metadata: { - url: params.url, - format: params.format, - timeout: params.timeout, - }, - }) - - const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT) - - const { signal, clearTimeout } = abortAfterAny(timeout, ctx.abort) - - // Build Accept header based on requested format with q parameters for fallbacks - let acceptHeader = "*/*" - switch (params.format) { - case "markdown": - acceptHeader = "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1" - break - case "text": - acceptHeader = "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1" - break - case "html": - acceptHeader = "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1" - break - default: - acceptHeader = - "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8" - } - const headers = { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", - Accept: acceptHeader, - "Accept-Language": "en-US,en;q=0.9", - } - - const response = await iife(async () => { - try { - const initial = await fetch(params.url, { signal, headers }) - - // Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch) - return initial.status === 403 && initial.headers.get("cf-mitigated") === "challenge" - ? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } }) - : initial - } finally { - clearTimeout() - } - }) - - if (!response.ok) { - throw new Error(`Request failed with status code: ${response.status}`) - } - - // Check content length - const contentLength = response.headers.get("content-length") - if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) { - throw new Error("Response too large (exceeds 5MB limit)") - } - - const arrayBuffer = await response.arrayBuffer() - if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) { - throw new Error("Response too large (exceeds 5MB limit)") - } - - const contentType = response.headers.get("content-type") || "" - const mime = contentType.split(";")[0]?.trim().toLowerCase() || "" - const title = `${params.url} (${contentType})` - - // Check if response is an image - const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet" - - if (isImage) { - const base64Content = Buffer.from(arrayBuffer).toString("base64") - return { - title, - output: "Image fetched successfully", - metadata: {}, - attachments: [ - { - type: "file", - mime, - url: `data:${mime};base64,${base64Content}`, - }, - ], - } - } - - const content = new TextDecoder().decode(arrayBuffer) - - // Handle content based on requested format and actual content type - switch (params.format) { - case "markdown": - if (contentType.includes("text/html")) { - const markdown = convertHTMLToMarkdown(content) - return { - output: markdown, - title, - metadata: {}, - } - } - return { - output: content, - title, - metadata: {}, - } - - case "text": - if (contentType.includes("text/html")) { - const text = await extractTextFromHTML(content) - return { - output: text, - title, - metadata: {}, - } - } - return { - output: content, - title, - metadata: {}, - } - - case "html": - return { - output: content, - title, - metadata: {}, - } - - default: - return { - output: content, - title, - metadata: {}, - } - } - }, +const parameters = z.object({ + url: z.string().describe("The URL to fetch content from"), + format: z + .enum(["text", "markdown", "html"]) + .default("markdown") + .describe("The format to return the content in (text, markdown, or html). Defaults to markdown."), + timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(), }) +export const WebFetchTool = Tool.define( + "webfetch", + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const httpOk = HttpClient.filterStatusOk(http) + + return { + description: DESCRIPTION, + parameters, + execute: (params: z.infer, ctx: Tool.Context) => + Effect.gen(function* () { + if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) { + throw new Error("URL must start with http:// or https://") + } + + yield* ctx.ask({ + permission: "webfetch", + patterns: [params.url], + always: ["*"], + metadata: { + url: params.url, + format: params.format, + timeout: params.timeout, + }, + }) + + const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT) + + // Build Accept header based on requested format with q parameters for fallbacks + let acceptHeader = "*/*" + switch (params.format) { + case "markdown": + acceptHeader = "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1" + break + case "text": + acceptHeader = "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1" + break + case "html": + acceptHeader = + "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1" + break + default: + acceptHeader = + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8" + } + const headers = { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + Accept: acceptHeader, + "Accept-Language": "en-US,en;q=0.9", + } + + const request = HttpClientRequest.get(params.url).pipe(HttpClientRequest.setHeaders(headers)) + + // Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch) + const response = yield* httpOk.execute(request).pipe( + Effect.catchIf( + (err) => + err.reason._tag === "StatusCodeError" && + err.reason.response.status === 403 && + err.reason.response.headers["cf-mitigated"] === "challenge", + () => + httpOk.execute( + HttpClientRequest.get(params.url).pipe( + HttpClientRequest.setHeaders({ ...headers, "User-Agent": "opencode" }), + ), + ), + ), + Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error("Request timed out")) }), + ) + + // Check content length + const contentLength = response.headers["content-length"] + if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) { + throw new Error("Response too large (exceeds 5MB limit)") + } + + const arrayBuffer = yield* response.arrayBuffer + if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) { + throw new Error("Response too large (exceeds 5MB limit)") + } + + const contentType = response.headers["content-type"] || "" + const mime = contentType.split(";")[0]?.trim().toLowerCase() || "" + const title = `${params.url} (${contentType})` + + // Check if response is an image + const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet" + + if (isImage) { + const base64Content = Buffer.from(arrayBuffer).toString("base64") + return { + title, + output: "Image fetched successfully", + metadata: {}, + attachments: [ + { + type: "file" as const, + mime, + url: `data:${mime};base64,${base64Content}`, + }, + ], + } + } + + const content = new TextDecoder().decode(arrayBuffer) + + // Handle content based on requested format and actual content type + switch (params.format) { + case "markdown": + if (contentType.includes("text/html")) { + const markdown = convertHTMLToMarkdown(content) + return { + output: markdown, + title, + metadata: {}, + } + } + return { output: content, title, metadata: {} } + + case "text": + if (contentType.includes("text/html")) { + const text = yield* Effect.promise(() => extractTextFromHTML(content)) + return { output: text, title, metadata: {} } + } + return { output: content, title, metadata: {} } + + case "html": + return { output: content, title, metadata: {} } + + default: + return { output: content, title, metadata: {} } + } + }).pipe(Effect.orDie), + } + }), +) + async function extractTextFromHTML(html: string) { let text = "" let skipContent = false diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index c0f1c8d10..968e1e34b 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -1,15 +1,9 @@ import z from "zod" +import { Effect } from "effect" +import { HttpClient } from "effect/unstable/http" import { Tool } from "./tool" +import * as McpExa from "./mcp-exa" import DESCRIPTION from "./websearch.txt" -import { abortAfterAny } from "../util/abort" - -const API_CONFIG = { - BASE_URL: "https://mcp.exa.ai", - ENDPOINTS: { - SEARCH: "/mcp", - }, - DEFAULT_NUM_RESULTS: 8, -} as const const Parameters = z.object({ query: z.string().describe("Websearch query"), @@ -30,121 +24,51 @@ const Parameters = z.object({ .describe("Maximum characters for context string optimized for LLMs (default: 10000)"), }) -interface McpSearchRequest { - jsonrpc: string - id: number - method: string - params: { - name: string - arguments: { - query: string - numResults?: number - livecrawl?: "fallback" | "preferred" - type?: "auto" | "fast" | "deep" - contextMaxCharacters?: number - } - } -} +export const WebSearchTool = Tool.define( + "websearch", + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient -interface McpSearchResponse { - jsonrpc: string - result: { - content: Array<{ - type: string - text: string - }> - } -} + return { + get description() { + return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString()) + }, + parameters: Parameters, + execute: (params: z.infer, ctx: Tool.Context) => + Effect.gen(function* () { + yield* ctx.ask({ + permission: "websearch", + patterns: [params.query], + always: ["*"], + metadata: { + query: params.query, + numResults: params.numResults, + livecrawl: params.livecrawl, + type: params.type, + contextMaxCharacters: params.contextMaxCharacters, + }, + }) -export const WebSearchTool = Tool.define("websearch", async () => { - return { - get description() { - return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString()) - }, - parameters: Parameters, - async execute(params, ctx) { - await ctx.ask({ - permission: "websearch", - patterns: [params.query], - always: ["*"], - metadata: { - query: params.query, - numResults: params.numResults, - livecrawl: params.livecrawl, - type: params.type, - contextMaxCharacters: params.contextMaxCharacters, - }, - }) + const result = yield* McpExa.call( + http, + "web_search_exa", + McpExa.SearchArgs, + { + query: params.query, + type: params.type || "auto", + numResults: params.numResults || 8, + livecrawl: params.livecrawl || "fallback", + contextMaxCharacters: params.contextMaxCharacters, + }, + "25 seconds", + ) - const searchRequest: McpSearchRequest = { - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { - name: "web_search_exa", - arguments: { - query: params.query, - type: params.type || "auto", - numResults: params.numResults || API_CONFIG.DEFAULT_NUM_RESULTS, - livecrawl: params.livecrawl || "fallback", - contextMaxCharacters: params.contextMaxCharacters, - }, - }, - } - - const { signal, clearTimeout } = abortAfterAny(25000, ctx.abort) - - try { - const headers: Record = { - accept: "application/json, text/event-stream", - "content-type": "application/json", - } - - const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.SEARCH}`, { - method: "POST", - headers, - body: JSON.stringify(searchRequest), - signal, - }) - - clearTimeout() - - if (!response.ok) { - const errorText = await response.text() - throw new Error(`Search error (${response.status}): ${errorText}`) - } - - const responseText = await response.text() - - // Parse SSE response - const lines = responseText.split("\n") - for (const line of lines) { - if (line.startsWith("data: ")) { - const data: McpSearchResponse = JSON.parse(line.substring(6)) - if (data.result && data.result.content && data.result.content.length > 0) { - return { - output: data.result.content[0].text, - title: `Web search: ${params.query}`, - metadata: {}, - } - } + return { + output: result ?? "No search results found. Please try a different query.", + title: `Web search: ${params.query}`, + metadata: {}, } - } - - return { - output: "No search results found. Please try a different query.", - title: `Web search: ${params.query}`, - metadata: {}, - } - } catch (error) { - clearTimeout() - - if (error instanceof Error && error.name === "AbortError") { - throw new Error("Search request timed out") - } - - throw error - } - }, - } -}) + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 6b134e525..7a9d82cf8 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -1,5 +1,6 @@ import z from "zod" import * as path from "path" +import { Effect } from "effect" import { Tool } from "./tool" import { LSP } from "../lsp" import { createTwoFilesPatch } from "diff" @@ -9,76 +10,87 @@ import { File } from "../file" import { FileWatcher } from "../file/watcher" import { Format } from "../format" import { FileTime } from "../file/time" -import { Filesystem } from "../util/filesystem" +import { AppFileSystem } from "../filesystem" import { Instance } from "../project/instance" import { trimDiff } from "./edit" -import { assertExternalDirectory } from "./external-directory" +import { assertExternalDirectoryEffect } from "./external-directory" -const MAX_DIAGNOSTICS_PER_FILE = 20 const MAX_PROJECT_DIAGNOSTICS_FILES = 5 -export const WriteTool = Tool.define("write", { - description: DESCRIPTION, - parameters: z.object({ - content: z.string().describe("The content to write to the file"), - filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"), - }), - async execute(params, ctx) { - const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) - await assertExternalDirectory(ctx, filepath) - - const exists = await Filesystem.exists(filepath) - const contentOld = exists ? await Filesystem.readText(filepath) : "" - if (exists) await FileTime.assert(ctx.sessionID, filepath) - - const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content)) - await ctx.ask({ - permission: "edit", - patterns: [path.relative(Instance.worktree, filepath)], - always: ["*"], - metadata: { - filepath, - diff, - }, - }) - - await Filesystem.write(filepath, params.content) - await Format.file(filepath) - Bus.publish(File.Event.Edited, { file: filepath }) - await Bus.publish(FileWatcher.Event.Updated, { - file: filepath, - event: exists ? "change" : "add", - }) - await FileTime.read(ctx.sessionID, filepath) - - let output = "Wrote file successfully." - await LSP.touchFile(filepath, true) - const diagnostics = await LSP.diagnostics() - const normalizedFilepath = Filesystem.normalizePath(filepath) - let projectDiagnosticsCount = 0 - for (const [file, issues] of Object.entries(diagnostics)) { - const errors = issues.filter((item) => item.severity === 1) - if (errors.length === 0) continue - const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) - const suffix = - errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" - if (file === normalizedFilepath) { - output += `\n\nLSP errors detected in this file, please fix:\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n` - continue - } - if (projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue - projectDiagnosticsCount++ - output += `\n\nLSP errors detected in other files:\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n` - } +export const WriteTool = Tool.define( + "write", + Effect.gen(function* () { + const lsp = yield* LSP.Service + const fs = yield* AppFileSystem.Service + const filetime = yield* FileTime.Service + const bus = yield* Bus.Service + const format = yield* Format.Service return { - title: path.relative(Instance.worktree, filepath), - metadata: { - diagnostics, - filepath, - exists: exists, - }, - output, + description: DESCRIPTION, + parameters: z.object({ + content: z.string().describe("The content to write to the file"), + filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"), + }), + execute: (params: { content: string; filePath: string }, ctx: Tool.Context) => + Effect.gen(function* () { + const filepath = path.isAbsolute(params.filePath) + ? params.filePath + : path.join(Instance.directory, params.filePath) + yield* assertExternalDirectoryEffect(ctx, filepath) + + const exists = yield* fs.existsSafe(filepath) + const contentOld = exists ? yield* fs.readFileString(filepath) : "" + if (exists) yield* filetime.assert(ctx.sessionID, filepath) + + const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content)) + yield* ctx.ask({ + permission: "edit", + patterns: [path.relative(Instance.worktree, filepath)], + always: ["*"], + metadata: { + filepath, + diff, + }, + }) + + yield* fs.writeWithDirs(filepath, params.content) + yield* format.file(filepath) + yield* bus.publish(File.Event.Edited, { file: filepath }) + yield* bus.publish(FileWatcher.Event.Updated, { + file: filepath, + event: exists ? "change" : "add", + }) + yield* filetime.read(ctx.sessionID, filepath) + + let output = "Wrote file successfully." + yield* lsp.touchFile(filepath, true) + const diagnostics = yield* lsp.diagnostics() + const normalizedFilepath = AppFileSystem.normalizePath(filepath) + let projectDiagnosticsCount = 0 + for (const [file, issues] of Object.entries(diagnostics)) { + const current = file === normalizedFilepath + if (!current && projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue + const block = LSP.Diagnostic.report(current ? filepath : file, issues) + if (!block) continue + if (current) { + output += `\n\nLSP errors detected in this file, please fix:\n${block}` + continue + } + projectDiagnosticsCount++ + output += `\n\nLSP errors detected in other files:\n${block}` + } + + return { + title: path.relative(Instance.worktree, filepath), + metadata: { + diagnostics, + filepath, + exists: exists, + }, + output, + } + }).pipe(Effect.orDie), } - }, -}) + }), +) diff --git a/packages/opencode/src/util/context.ts b/packages/opencode/src/util/local-context.ts similarity index 94% rename from packages/opencode/src/util/context.ts rename to packages/opencode/src/util/local-context.ts index 46bbf4608..26f88ab09 100644 --- a/packages/opencode/src/util/context.ts +++ b/packages/opencode/src/util/local-context.ts @@ -1,6 +1,6 @@ import { AsyncLocalStorage } from "async_hooks" -export namespace Context { +export namespace LocalContext { export class NotFound extends Error { constructor(public override readonly name: string) { super(`No context found for ${name}`) diff --git a/packages/opencode/src/util/log.ts b/packages/opencode/src/util/log.ts index 2ca4c0a3d..f94e9866f 100644 --- a/packages/opencode/src/util/log.ts +++ b/packages/opencode/src/util/log.ts @@ -15,6 +15,7 @@ export namespace Log { WARN: 2, ERROR: 3, } + const keep = 10 let level: Level = "INFO" @@ -78,15 +79,19 @@ export namespace Log { } async function cleanup(dir: string) { - const files = await Glob.scan("????-??-??T??????.log", { - cwd: dir, - absolute: true, - include: "file", - }) - if (files.length <= 5) return + const files = ( + await Glob.scan("????-??-??T??????.log", { + cwd: dir, + absolute: false, + include: "file", + }).catch(() => []) + ) + .filter((file) => path.basename(file) === file) + .sort() + if (files.length <= keep) return - const filesToDelete = files.slice(0, -10) - await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {}))) + const doomed = files.slice(0, -keep) + await Promise.all(doomed.map((file) => fs.unlink(path.join(dir, file)).catch(() => {}))) } function formatError(error: Error, depth = 0): string { diff --git a/packages/opencode/src/util/schema.ts b/packages/opencode/src/util/schema.ts index 6a88dba53..405f6a718 100644 --- a/packages/opencode/src/util/schema.ts +++ b/packages/opencode/src/util/schema.ts @@ -6,7 +6,7 @@ import { Schema } from "effect" * @example * export const Foo = fooSchema.pipe( * withStatics((schema) => ({ - * zero: schema.makeUnsafe(0), + * zero: schema.make(0), * from: Schema.decodeUnknownOption(schema), * })) * ) @@ -26,7 +26,7 @@ type NewtypeBrand = { readonly [NewtypeBrand]: Tag } * @example * class QuestionID extends Newtype()("QuestionID", Schema.String) { * static make(id: string): QuestionID { - * return this.makeUnsafe(id) + * return this.make(id) * } * } * @@ -39,7 +39,7 @@ export function Newtype() { abstract class Base { declare readonly [NewtypeBrand]: Tag - static makeUnsafe(value: Schema.Schema.Type): Self { + static make(value: Schema.Schema.Type): Self { return value as unknown as Self } } @@ -47,7 +47,7 @@ export function Newtype() { Object.setPrototypeOf(Base, schema) return Base as unknown as (abstract new (_: never) => Branded) & { - readonly makeUnsafe: (value: Schema.Schema.Type) => Self - } & Omit, "makeUnsafe"> + readonly make: (value: Schema.Schema.Type) => Self + } & Omit, "make"> } } diff --git a/packages/opencode/src/v2/message.ts b/packages/opencode/src/v2/message.ts new file mode 100644 index 000000000..868ab8280 --- /dev/null +++ b/packages/opencode/src/v2/message.ts @@ -0,0 +1,115 @@ +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("Message.Source")({ + start: Schema.Number, + end: Schema.Number, + text: Schema.String, + }) {} + + export class FileAttachment extends Schema.Class("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("Message.Agent.Attachment")({ + name: Schema.String, + source: Source.pipe(Schema.optional), + }) {} + + export class User extends Schema.Class("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("Message.Synthetic")({ + id: ID, + type: Schema.Literal("synthetic"), + text: Schema.String, + time: Schema.Struct({ + created: Schema.DateTimeUtc, + }), + }) {} + + export class Request extends Schema.Class("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("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("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 +} diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts new file mode 100644 index 000000000..4b4fa1978 --- /dev/null +++ b/packages/opencode/src/v2/session.ts @@ -0,0 +1,71 @@ +import { Context, Layer, Schema, Effect } from "effect" +import { Message } from "./message" +import { Struct } from "effect" +import { Identifier } from "@/id/id" +import { withStatics } from "@/util/schema" +import { Session } from "@/session" +import { SessionID } from "@/session/schema" + +export namespace SessionV2 { + export const ID = SessionID + + export type ID = Schema.Schema.Type + + export class PromptInput extends Schema.Class("Session.PromptInput")({ + ...Struct.omit(Message.User.fields, ["time", "type"]), + id: Schema.optionalKey(Message.ID), + sessionID: SessionV2.ID, + }) {} + + export class CreateInput extends Schema.Class("Session.CreateInput")({ + id: Schema.optionalKey(SessionV2.ID), + }) {} + + export class Info extends Schema.Class("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 + create: (input: CreateInput) => Effect.Effect + prompt: (input: PromptInput) => Effect.Effect + } + + export class Service extends Context.Service()("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), + }) + } +} diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index b34364ccd..f4ec0af83 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -13,7 +13,7 @@ import { errorMessage } from "../util/error" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { Git } from "@/git" -import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect" +import { Effect, Layer, Path, Scope, Context, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@/filesystem" @@ -164,14 +164,14 @@ export namespace Worktree { readonly reset: (input: ResetInput) => Effect.Effect } - export class Service extends ServiceMap.Service()("@opencode/Worktree") {} + export class Service extends Context.Service()("@opencode/Worktree") {} type GitResult = { code: number; text: string; stderr: string } export const layer: Layer.Layer< Service, never, - AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Project.Service + AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Git.Service | Project.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -179,6 +179,7 @@ export namespace Worktree { const fs = yield* AppFileSystem.Service const pathSvc = yield* Path.Path const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const gitSvc = yield* Git.Service const project = yield* Project.Service const git = Effect.fnUntraced( @@ -245,6 +246,7 @@ export namespace Worktree { const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) { const ctx = yield* InstanceState.context + const workspaceID = yield* InstanceState.workspaceID const projectID = ctx.project.id const extra = startCommand?.trim() @@ -254,6 +256,8 @@ export namespace Worktree { log.error("worktree checkout failed", { directory: info.directory, message }) GlobalBus.emit("event", { directory: info.directory, + project: ctx.project.id, + workspace: workspaceID, payload: { type: Event.Failed.type, properties: { message } }, }) return @@ -271,6 +275,8 @@ export namespace Worktree { log.error("worktree bootstrap failed", { directory: info.directory, message }) GlobalBus.emit("event", { directory: info.directory, + project: ctx.project.id, + workspace: workspaceID, payload: { type: Event.Failed.type, properties: { message } }, }) return false @@ -280,6 +286,8 @@ export namespace Worktree { GlobalBus.emit("event", { directory: info.directory, + project: ctx.project.id, + workspace: workspaceID, payload: { type: Event.Ready.type, properties: { name: info.name, branch: info.branch }, @@ -516,7 +524,7 @@ export namespace Worktree { const worktreePath = entry.path - const base = yield* Effect.promise(() => Git.defaultBranch(Instance.worktree)) + const base = yield* gitSvc.defaultBranch(Instance.worktree) if (!base) { throw new ResetFailedError({ message: "Default branch not found" }) } @@ -582,7 +590,8 @@ export namespace Worktree { }), ) - const defaultLayer = layer.pipe( + export const defaultLayer = layer.pipe( + Layer.provide(Git.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Project.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), diff --git a/packages/opencode/test/AGENTS.md b/packages/opencode/test/AGENTS.md index 209cf72da..00564a17b 100644 --- a/packages/opencode/test/AGENTS.md +++ b/packages/opencode/test/AGENTS.md @@ -79,3 +79,55 @@ await using tmp = await tmpdir({ - Directories are created in the system temp folder with prefix `opencode-test-` - Use `await using` for automatic cleanup when the variable goes out of scope - Paths are sanitized to strip null bytes (defensive fix for CI environments) + +## Testing With Effects + +Use `testEffect(...)` from `test/lib/effect.ts` for tests that exercise Effect services or Effect-based workflows. + +### Core Pattern + +```typescript +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(MyService.defaultLayer)) + +describe("my service", () => { + it.live("does the thing", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const svc = yield* MyService.Service + const out = yield* svc.run() + expect(out).toEqual("ok") + }), + ), + ) +}) +``` + +### `it.effect` vs `it.live` + +- Use `it.effect(...)` when the test should run with `TestClock` and `TestConsole`. +- Use `it.live(...)` when the test depends on real time, filesystem mtimes, child processes, git, locks, or other live OS behavior. +- Most integration-style tests in this package use `it.live(...)`. + +### Effect Fixtures + +Prefer the Effect-aware helpers from `fixture/fixture.ts` instead of building a manual runtime in each test. + +- `tmpdirScoped(options?)` creates a scoped temp directory and cleans it up when the Effect scope closes. +- `provideInstance(dir)(effect)` is the low-level helper. It does not create a directory; it just runs an Effect with `Instance.current` bound to `dir`. +- `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup. +- `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server. + +Use `provideTmpdirInstance(...)` by default when a test only needs one temp instance. Use `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, or needs to switch instance context within one test. + +### Style + +- Define `const it = testEffect(...)` near the top of the file. +- Keep the test body inside `Effect.gen(function* () { ... })`. +- Yield services directly with `yield* MyService.Service` or `yield* MyTool`. +- Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` wrappers when `testEffect(...)` already provides the runtime. +- When a test needs instance-local state, prefer `provideTmpdirInstance(...)` or `provideInstance(...)` over manual `Instance.provide(...)` inside Promise-style tests. diff --git a/packages/opencode/test/cli/tui/sync-provider.test.tsx b/packages/opencode/test/cli/tui/sync-provider.test.tsx new file mode 100644 index 000000000..3ef126ef4 --- /dev/null +++ b/packages/opencode/test/cli/tui/sync-provider.test.tsx @@ -0,0 +1,292 @@ +/** @jsxImportSource @opentui/solid */ +import { afterEach, describe, expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { onMount } from "solid-js" +import { ArgsProvider } from "../../../src/cli/cmd/tui/context/args" +import { ExitProvider } from "../../../src/cli/cmd/tui/context/exit" +import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project" +import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk" +import { SyncProvider, useSync } from "../../../src/cli/cmd/tui/context/sync" + +const sighup = new Set(process.listeners("SIGHUP")) + +afterEach(() => { + for (const fn of process.listeners("SIGHUP")) { + if (!sighup.has(fn)) process.off("SIGHUP", fn) + } +}) + +function json(data: unknown) { + return new Response(JSON.stringify(data), { + headers: { + "content-type": "application/json", + }, + }) +} + +async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +function data(workspace?: string | null) { + const tag = workspace ?? "root" + return { + session: { + id: "ses_1", + title: `session-${tag}`, + workspaceID: workspace ?? undefined, + time: { + updated: 1, + }, + }, + message: { + info: { + id: "msg_1", + sessionID: "ses_1", + role: "assistant", + time: { + created: 1, + completed: 1, + }, + }, + parts: [ + { + id: "part_1", + messageID: "msg_1", + sessionID: "ses_1", + type: "text", + text: `part-${tag}`, + }, + ], + }, + todo: [ + { + id: `todo-${tag}`, + content: `todo-${tag}`, + status: "pending", + priority: "medium", + }, + ], + diff: [ + { + file: `${tag}.ts`, + patch: "", + additions: 0, + deletions: 0, + }, + ], + } +} + +type Hit = { + path: string + workspace?: string +} + +function createFetch(log: Hit[]) { + return Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init) + const url = new URL(req.url) + const workspace = url.searchParams.get("workspace") ?? req.headers.get("x-opencode-workspace") ?? undefined + log.push({ + path: url.pathname, + workspace, + }) + + if (url.pathname === "/config/providers") { + return json({ providers: [], default: {} }) + } + if (url.pathname === "/provider") { + return json({ all: [], default: {}, connected: [] }) + } + if (url.pathname === "/experimental/console") { + return json({}) + } + if (url.pathname === "/agent") { + return json([]) + } + if (url.pathname === "/config") { + return json({}) + } + if (url.pathname === "/project/current") { + return json({ id: `proj-${workspace ?? "root"}` }) + } + if (url.pathname === "/path") { + return json({ + state: `/tmp/${workspace ?? "root"}/state`, + config: `/tmp/${workspace ?? "root"}/config`, + worktree: "/tmp/worktree", + directory: `/tmp/${workspace ?? "root"}`, + }) + } + if (url.pathname === "/session") { + return json([]) + } + if (url.pathname === "/command") { + return json([]) + } + if (url.pathname === "/lsp") { + return json([]) + } + if (url.pathname === "/mcp") { + return json({}) + } + if (url.pathname === "/experimental/resource") { + return json({}) + } + if (url.pathname === "/formatter") { + return json([]) + } + if (url.pathname === "/session/status") { + return json({}) + } + if (url.pathname === "/provider/auth") { + return json({}) + } + if (url.pathname === "/vcs") { + return json({ branch: "main" }) + } + if (url.pathname === "/experimental/workspace") { + return json([{ id: "ws_a" }, { id: "ws_b" }]) + } + if (url.pathname === "/session/ses_1") { + return json(data(workspace).session) + } + if (url.pathname === "/session/ses_1/message") { + return json([data(workspace).message]) + } + if (url.pathname === "/session/ses_1/todo") { + return json(data(workspace).todo) + } + if (url.pathname === "/session/ses_1/diff") { + return json(data(workspace).diff) + } + + throw new Error(`unexpected request: ${req.method} ${url.pathname}`) + }, + { preconnect: fetch.preconnect.bind(fetch) }, + ) satisfies typeof fetch +} + +async function mount(log: Hit[]) { + let project!: ReturnType + let sync!: ReturnType + let done!: () => void + const ready = new Promise((resolve) => { + done = resolve + }) + + const app = await testRender(() => ( + () => {} }} + > + + + + + { + project = ctx.project + sync = ctx.sync + done() + }} + /> + + + + + + )) + + await ready + return { app, project, sync } +} + +async function waitBoot(log: Hit[], workspace?: string) { + await wait(() => log.some((item) => item.path === "/experimental/workspace")) + if (!workspace) return + await wait(() => log.some((item) => item.path === "/project/current" && item.workspace === workspace)) +} + +function Probe(props: { + onReady: (ctx: { project: ReturnType; sync: ReturnType }) => void +}) { + const project = useProject() + const sync = useSync() + + onMount(() => { + props.onReady({ project, sync }) + }) + + return +} + +describe("SyncProvider", () => { + test("re-runs bootstrap requests when the active workspace changes", async () => { + const log: Hit[] = [] + const { app, project } = await mount(log) + + try { + await waitBoot(log) + log.length = 0 + + project.workspace.set("ws_a") + + await waitBoot(log, "ws_a") + + expect(log.some((item) => item.path === "/path" && item.workspace === "ws_a")).toBe(true) + expect(log.some((item) => item.path === "/config" && item.workspace === "ws_a")).toBe(true) + expect(log.some((item) => item.path === "/command" && item.workspace === "ws_a")).toBe(true) + } finally { + app.renderer.destroy() + } + }) + + test("clears full-sync cache when the active workspace changes", async () => { + const log: Hit[] = [] + const { app, project, sync } = await mount(log) + + try { + await waitBoot(log) + + log.length = 0 + project.workspace.set("ws_a") + await waitBoot(log, "ws_a") + expect(project.workspace.current()).toBe("ws_a") + + log.length = 0 + await sync.session.sync("ses_1") + + expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_a")).toHaveLength(1) + expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_a") + expect(sync.data.message.ses_1[0]?.id).toBe("msg_1") + expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_a" }) + expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_a.ts") + + log.length = 0 + project.workspace.set("ws_b") + await waitBoot(log, "ws_b") + expect(project.workspace.current()).toBe("ws_b") + + log.length = 0 + await sync.session.sync("ses_1") + await wait(() => log.some((item) => item.path === "/session/ses_1" && item.workspace === "ws_b")) + + expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_b")).toHaveLength(1) + expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_b") + expect(sync.data.message.ses_1[0]?.id).toBe("msg_1") + expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_b" }) + expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_b.ts") + } finally { + app.renderer.destroy() + } + }) +}) diff --git a/packages/opencode/test/cli/tui/use-event.test.tsx b/packages/opencode/test/cli/tui/use-event.test.tsx new file mode 100644 index 000000000..5b0fcad3c --- /dev/null +++ b/packages/opencode/test/cli/tui/use-event.test.tsx @@ -0,0 +1,175 @@ +/** @jsxImportSource @opentui/solid */ +import { describe, expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2" +import { onMount } from "solid-js" +import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project" +import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk" +import { useEvent } from "../../../src/cli/cmd/tui/context/event" + +async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +function event(payload: Event, input: { directory: string; workspace?: string }): GlobalEvent { + return { + directory: input.directory, + workspace: input.workspace, + payload, + } +} + +function vcs(branch: string): Event { + return { + type: "vcs.branch.updated", + properties: { + branch, + }, + } +} + +function update(version: string): Event { + return { + type: "installation.update-available", + properties: { + version, + }, + } +} + +function createSource() { + let fn: ((event: GlobalEvent) => void) | undefined + + return { + source: { + subscribe: async (handler: (event: GlobalEvent) => void) => { + fn = handler + return () => { + if (fn === handler) fn = undefined + } + }, + }, + emit(evt: GlobalEvent) { + if (!fn) throw new Error("event source not ready") + fn(evt) + }, + } +} + +async function mount() { + const source = createSource() + const seen: Event[] = [] + let project!: ReturnType + let done!: () => void + const ready = new Promise((resolve) => { + done = resolve + }) + + const app = await testRender(() => ( + + + { + project = ctx.project + done() + }} + seen={seen} + /> + + + )) + + await ready + return { app, emit: source.emit, project, seen } +} + +function Probe(props: { seen: Event[]; onReady: (ctx: { project: ReturnType }) => void }) { + const project = useProject() + const event = useEvent() + + onMount(() => { + event.subscribe((evt) => { + props.seen.push(evt) + }) + props.onReady({ project }) + }) + + return +} + +describe("useEvent", () => { + test("delivers matching directory events without an active workspace", async () => { + const { app, emit, seen } = await mount() + + try { + emit(event(vcs("main"), { directory: "/tmp/root" })) + + await wait(() => seen.length === 1) + + expect(seen).toEqual([vcs("main")]) + } finally { + app.renderer.destroy() + } + }) + + test("ignores non-matching directory events without an active workspace", async () => { + const { app, emit, seen } = await mount() + + try { + emit(event(vcs("other"), { directory: "/tmp/other" })) + await Bun.sleep(30) + + expect(seen).toHaveLength(0) + } finally { + app.renderer.destroy() + } + }) + + test("delivers matching workspace events when a workspace is active", async () => { + const { app, emit, project, seen } = await mount() + + try { + project.workspace.set("ws_a") + emit(event(vcs("ws"), { directory: "/tmp/other", workspace: "ws_a" })) + + await wait(() => seen.length === 1) + + expect(seen).toEqual([vcs("ws")]) + } finally { + app.renderer.destroy() + } + }) + + test("ignores non-matching workspace events when a workspace is active", async () => { + const { app, emit, project, seen } = await mount() + + try { + project.workspace.set("ws_a") + emit(event(vcs("ws"), { directory: "/tmp/root", workspace: "ws_b" })) + await Bun.sleep(30) + + expect(seen).toHaveLength(0) + } finally { + app.renderer.destroy() + } + }) + + test("delivers truly global events even when a workspace is active", async () => { + const { app, emit, project, seen } = await mount() + + try { + project.workspace.set("ws_a") + emit(event(update("1.2.3"), { directory: "global" })) + + await wait(() => seen.length === 1) + + expect(seen).toEqual([update("1.2.3")]) + } finally { + app.renderer.destroy() + } + }) +}) diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts index 914753312..813ca344a 100644 --- a/packages/opencode/test/effect/instance-state.test.ts +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, test } from "bun:test" -import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer, ManagedRuntime, ServiceMap } from "effect" +import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer, ManagedRuntime, Context } from "effect" import { InstanceState } from "../../src/effect/instance-state" import { InstanceRef } from "../../src/effect/instance-ref" import { Instance } from "../../src/project/instance" @@ -122,7 +122,7 @@ test("InstanceState.get reads the current directory lazily", async () => { readonly get: () => Effect.Effect } - class Test extends ServiceMap.Service()("@test/InstanceStateLazy") { + class Test extends Context.Service()("@test/InstanceStateLazy") { static readonly layer = Layer.effect( Test, Effect.gen(function* () { @@ -166,7 +166,7 @@ test("InstanceState preserves directory across async boundaries", async () => { readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }> } - class Test extends ServiceMap.Service()("@test/InstanceStateAsync") { + class Test extends Context.Service()("@test/InstanceStateAsync") { static readonly layer = Layer.effect( Test, Effect.gen(function* () { @@ -234,7 +234,7 @@ test("InstanceState survives high-contention concurrent access", async () => { readonly get: () => Effect.Effect } - class Test extends ServiceMap.Service()("@test/HighContention") { + class Test extends Context.Service()("@test/HighContention") { static readonly layer = Layer.effect( Test, Effect.gen(function* () { @@ -284,7 +284,7 @@ test("InstanceState correct after interleaved init and dispose", async () => { readonly get: () => Effect.Effect } - class Test extends ServiceMap.Service()("@test/InterleavedDispose") { + class Test extends Context.Service()("@test/InterleavedDispose") { static readonly layer = Layer.effect( Test, Effect.gen(function* () { @@ -391,7 +391,7 @@ test("InstanceState survives deferred resume from the same instance context", as readonly get: (gate: Deferred.Deferred) => Effect.Effect } - class Test extends ServiceMap.Service()("@test/DeferredResume") { + class Test extends Context.Service()("@test/DeferredResume") { static readonly layer = Layer.effect( Test, Effect.gen(function* () { @@ -438,7 +438,7 @@ test("InstanceState survives deferred resume outside ALS when InstanceRef is set readonly get: (gate: Deferred.Deferred) => Effect.Effect } - class Test extends ServiceMap.Service()("@test/DeferredResumeOutside") { + class Test extends Context.Service()("@test/DeferredResumeOutside") { static readonly layer = Layer.effect( Test, Effect.gen(function* () { diff --git a/packages/opencode/test/effect/run-service.test.ts b/packages/opencode/test/effect/run-service.test.ts index b2004fb66..b5f1a1d09 100644 --- a/packages/opencode/test/effect/run-service.test.ts +++ b/packages/opencode/test/effect/run-service.test.ts @@ -1,8 +1,8 @@ import { expect, test } from "bun:test" -import { Effect, Layer, ServiceMap } from "effect" +import { Effect, Layer, Context } from "effect" import { makeRuntime } from "../../src/effect/run-service" -class Shared extends ServiceMap.Service()("@test/Shared") {} +class Shared extends Context.Service()("@test/Shared") {} test("makeRuntime shares dependent layers through the shared memo map", async () => { let n = 0 @@ -15,7 +15,7 @@ test("makeRuntime shares dependent layers through the shared memo map", async () }), ) - class One extends ServiceMap.Service Effect.Effect }>()("@test/One") {} + class One extends Context.Service Effect.Effect }>()("@test/One") {} const one = Layer.effect( One, Effect.gen(function* () { @@ -26,7 +26,7 @@ test("makeRuntime shares dependent layers through the shared memo map", async () }), ).pipe(Layer.provide(shared)) - class Two extends ServiceMap.Service Effect.Effect }>()("@test/Two") {} + class Two extends Context.Service Effect.Effect }>()("@test/Two") {} const two = Layer.effect( Two, Effect.gen(function* () { diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index 9dc395876..a91df76eb 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -250,7 +250,7 @@ describe("Runner", () => { Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) - const result = yield* runner.startShell((_signal) => Effect.succeed("shell-done")) + const result = yield* runner.startShell(Effect.succeed("shell-done")) expect(result).toBe("shell-done") expect(runner.busy).toBe(false) }), @@ -264,7 +264,7 @@ describe("Runner", () => { const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") - const exit = yield* runner.startShell((_s) => Effect.succeed("nope")).pipe(Effect.exit) + const exit = yield* runner.startShell(Effect.succeed("nope")).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) yield* runner.cancel @@ -279,12 +279,10 @@ describe("Runner", () => { const runner = Runner.make(s) const gate = yield* Deferred.make() - const sh = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("first"))) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("first"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") - const exit = yield* runner.startShell((_s) => Effect.succeed("second")).pipe(Effect.exit) + const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) yield* Deferred.succeed(gate, undefined) @@ -302,37 +300,26 @@ describe("Runner", () => { }, }) - const sh = yield* runner - .startShell((signal) => - Effect.promise( - () => - new Promise((resolve) => { - signal.addEventListener("abort", () => resolve("aborted"), { once: true }) - }), - ), - ) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") - const exit = yield* runner.startShell((_s) => Effect.succeed("second")).pipe(Effect.exit) + const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) yield* runner.cancel const done = yield* Fiber.await(sh) - expect(Exit.isSuccess(done)).toBe(true) + expect(Exit.isFailure(done)).toBe(true) }), ) it.live( - "cancel interrupts shell that ignores abort signal", + "cancel interrupts shell", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) const gate = yield* Deferred.make() - const sh = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("ignored"))) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ignored"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") const stop = yield* runner.cancel.pipe(Effect.forkChild) @@ -356,9 +343,7 @@ describe("Runner", () => { const runner = Runner.make(s) const gate = yield* Deferred.make() - const sh = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("shell-result"))) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell-result"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") expect(runner.state._tag).toBe("Shell") @@ -384,9 +369,7 @@ describe("Runner", () => { const calls = yield* Ref.make(0) const gate = yield* Deferred.make() - const sh = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("shell"))) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") const work = Effect.gen(function* () { @@ -414,16 +397,7 @@ describe("Runner", () => { const runner = Runner.make(s) const gate = yield* Deferred.make() - const sh = yield* runner - .startShell((signal) => - Effect.promise( - () => - new Promise((resolve) => { - signal.addEventListener("abort", () => resolve("aborted"), { once: true }) - }), - ), - ) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") const run = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild) @@ -478,7 +452,7 @@ describe("Runner", () => { const runner = Runner.make(s, { onBusy: Ref.update(count, (n) => n + 1), }) - yield* runner.startShell((_signal) => Effect.succeed("done")) + yield* runner.startShell(Effect.succeed("done")) expect(yield* Ref.get(count)).toBe(1) }), ) @@ -509,9 +483,7 @@ describe("Runner", () => { const runner = Runner.make(s) const gate = yield* Deferred.make() - const fiber = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("ok"))) - .pipe(Effect.forkChild) + const fiber = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ok"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") expect(runner.busy).toBe(true) diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts index 5eb56e53d..3982c184f 100644 --- a/packages/opencode/test/file/ripgrep.test.ts +++ b/packages/opencode/test/file/ripgrep.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import * as Stream from "effect/Stream" import fs from "fs/promises" import path from "path" import { tmpdir } from "../fixture/fixture" @@ -52,3 +54,52 @@ describe("file.ripgrep", () => { expect(hits).toEqual([]) }) }) + +describe("Ripgrep.Service", () => { + test("files returns stream of filenames", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "a.txt"), "hello") + await Bun.write(path.join(dir, "b.txt"), "world") + }, + }) + + const files = await Effect.gen(function* () { + const rg = yield* Ripgrep.Service + return yield* rg.files({ cwd: tmp.path }).pipe( + Stream.runCollect, + Effect.map((chunk) => [...chunk].sort()), + ) + }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise) + + expect(files).toEqual(["a.txt", "b.txt"]) + }) + + test("files respects glob filter", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "keep.ts"), "yes") + await Bun.write(path.join(dir, "skip.txt"), "no") + }, + }) + + const files = await Effect.gen(function* () { + const rg = yield* Ripgrep.Service + return yield* rg.files({ cwd: tmp.path, glob: ["*.ts"] }).pipe( + Stream.runCollect, + Effect.map((chunk) => [...chunk]), + ) + }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise) + + expect(files).toEqual(["keep.ts"]) + }) + + test("files dies on nonexistent directory", async () => { + const exit = await Effect.gen(function* () { + const rg = yield* Ripgrep.Service + return yield* rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect) + }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromiseExit) + + expect(exit._tag).toBe("Failure") + }) +}) diff --git a/packages/opencode/test/file/time.test.ts b/packages/opencode/test/file/time.test.ts index ab7659c59..7f65d05ea 100644 --- a/packages/opencode/test/file/time.test.ts +++ b/packages/opencode/test/file/time.test.ts @@ -1,445 +1,422 @@ -import { describe, test, expect, afterEach } from "bun:test" -import path from "path" +import { afterEach, describe, expect } from "bun:test" import fs from "fs/promises" +import path from "path" +import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { FileTime } from "../../src/file/time" import { Instance } from "../../src/project/instance" import { SessionID } from "../../src/session/schema" import { Filesystem } from "../../src/util/filesystem" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" afterEach(async () => { await Instance.disposeAll() }) -async function touch(file: string, time: number) { - const date = new Date(time) - await fs.utimes(file, date, date) -} +const it = testEffect(Layer.mergeAll(FileTime.defaultLayer, CrossSpawnSpawner.defaultLayer)) -function gate() { - let open!: () => void - const wait = new Promise((resolve) => { - open = resolve +const id = SessionID.make("ses_00000000000000000000000001") + +const put = (file: string, text: string) => Effect.promise(() => fs.writeFile(file, text, "utf-8")) + +const touch = (file: string, time: number) => + Effect.promise(() => { + const date = new Date(time) + return fs.utimes(file, date, date) }) - return { open, wait } -} + +const read = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.read(id, file)) + +const get = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.get(id, file)) + +const check = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.assert(id, file)) + +const lock = (file: string, fn: () => Effect.Effect) => FileTime.Service.use((svc) => svc.withLock(file, fn)) + +const fail = Effect.fn("FileTimeTest.fail")(function* (self: Effect.Effect) { + const exit = yield* self.pipe(Effect.exit) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + return err instanceof Error ? err : new Error(String(err)) + } + throw new Error("expected file time effect to fail") +}) describe("file/time", () => { - const sessionID = SessionID.make("ses_00000000000000000000000001") - describe("read() and get()", () => { - test("stores read timestamp", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") + it.live("stores read timestamp", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const file = path.join(dir, "file.txt") + yield* put(file, "content") - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const before = await FileTime.get(sessionID, filepath) + const before = yield* get(id, file) expect(before).toBeUndefined() - await FileTime.read(sessionID, filepath) + yield* read(id, file) - const after = await FileTime.get(sessionID, filepath) + const after = yield* get(id, file) expect(after).toBeInstanceOf(Date) expect(after!.getTime()).toBeGreaterThan(0) - }, - }) - }) + }), + ), + ) - test("tracks separate timestamps per session", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") + it.live("tracks separate timestamps per session", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const file = path.join(dir, "file.txt") + yield* put(file, "content") - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await FileTime.read(SessionID.make("ses_00000000000000000000000002"), filepath) - await FileTime.read(SessionID.make("ses_00000000000000000000000003"), filepath) + const one = SessionID.make("ses_00000000000000000000000002") + const two = SessionID.make("ses_00000000000000000000000003") + yield* read(one, file) + yield* read(two, file) - const time1 = await FileTime.get(SessionID.make("ses_00000000000000000000000002"), filepath) - const time2 = await FileTime.get(SessionID.make("ses_00000000000000000000000003"), filepath) + const first = yield* get(one, file) + const second = yield* get(two, file) - expect(time1).toBeDefined() - expect(time2).toBeDefined() - }, - }) - }) + expect(first).toBeDefined() + expect(second).toBeDefined() + }), + ), + ) - test("updates timestamp on subsequent reads", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") + it.live("updates timestamp on subsequent reads", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const file = path.join(dir, "file.txt") + yield* put(file, "content") - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await FileTime.read(sessionID, filepath) - const first = await FileTime.get(sessionID, filepath) + yield* read(id, file) + const first = yield* get(id, file) - await FileTime.read(sessionID, filepath) - const second = await FileTime.get(sessionID, filepath) + yield* read(id, file) + const second = yield* get(id, file) expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime()) - }, - }) - }) + }), + ), + ) - test("isolates reads by directory", async () => { - await using one = await tmpdir() - await using two = await tmpdir() - await using shared = await tmpdir() - const filepath = path.join(shared.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") + it.live("isolates reads by directory", () => + Effect.gen(function* () { + const one = yield* tmpdirScoped() + const two = yield* tmpdirScoped() + const shared = yield* tmpdirScoped() + const file = path.join(shared, "file.txt") + yield* put(file, "content") - await Instance.provide({ - directory: one.path, - fn: async () => { - await FileTime.read(sessionID, filepath) - }, - }) - - await Instance.provide({ - directory: two.path, - fn: async () => { - expect(await FileTime.get(sessionID, filepath)).toBeUndefined() - }, - }) - }) + yield* provideInstance(one)(read(id, file)) + const result = yield* provideInstance(two)(get(id, file)) + expect(result).toBeUndefined() + }), + ) }) describe("assert()", () => { - test("passes when file has not been modified", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") - await touch(filepath, 1_000) + it.live("passes when file has not been modified", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const file = path.join(dir, "file.txt") + yield* put(file, "content") + yield* touch(file, 1_000) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await FileTime.read(sessionID, filepath) - await FileTime.assert(sessionID, filepath) - }, - }) - }) + yield* read(id, file) + yield* check(id, file) + }), + ), + ) - test("throws when file was not read first", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") + it.live("throws when file was not read first", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const file = path.join(dir, "file.txt") + yield* put(file, "content") - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("You must read file") - }, - }) - }) + const err = yield* fail(check(id, file)) + expect(err.message).toContain("You must read file") + }), + ), + ) - test("throws when file was modified after read", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") - await touch(filepath, 1_000) + it.live("throws when file was modified after read", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const file = path.join(dir, "file.txt") + yield* put(file, "content") + yield* touch(file, 1_000) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await FileTime.read(sessionID, filepath) - await fs.writeFile(filepath, "modified content", "utf-8") - await touch(filepath, 2_000) - await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read") - }, - }) - }) + yield* read(id, file) + yield* put(file, "modified content") + yield* touch(file, 2_000) - test("includes timestamps in error message", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") - await touch(filepath, 1_000) + const err = yield* fail(check(id, file)) + expect(err.message).toContain("modified since it was last read") + }), + ), + ) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await FileTime.read(sessionID, filepath) - await fs.writeFile(filepath, "modified", "utf-8") - await touch(filepath, 2_000) + it.live("includes timestamps in error message", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const file = path.join(dir, "file.txt") + yield* put(file, "content") + yield* touch(file, 1_000) - let error: Error | undefined - try { - await FileTime.assert(sessionID, filepath) - } catch (e) { - error = e as Error - } - expect(error).toBeDefined() - expect(error!.message).toContain("Last modification:") - expect(error!.message).toContain("Last read:") - }, - }) - }) + yield* read(id, file) + yield* put(file, "modified") + yield* touch(file, 2_000) + + const err = yield* fail(check(id, file)) + expect(err.message).toContain("Last modification:") + expect(err.message).toContain("Last read:") + }), + ), + ) }) describe("withLock()", () => { - test("executes function within lock", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") + it.live("executes function within lock", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const file = path.join(dir, "file.txt") + let hit = false - await Instance.provide({ - directory: tmp.path, - fn: async () => { - let executed = false - await FileTime.withLock(filepath, async () => { - executed = true - return "result" - }) - expect(executed).toBe(true) - }, - }) - }) - - test("returns function result", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const result = await FileTime.withLock(filepath, async () => { - return "success" - }) - expect(result).toBe("success") - }, - }) - }) - - test("serializes concurrent operations on same file", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const order: number[] = [] - const hold = gate() - const ready = gate() - - const op1 = FileTime.withLock(filepath, async () => { - order.push(1) - ready.open() - await hold.wait - order.push(2) - }) - - await ready.wait - - const op2 = FileTime.withLock(filepath, async () => { - order.push(3) - order.push(4) - }) - - hold.open() - - await Promise.all([op1, op2]) - expect(order).toEqual([1, 2, 3, 4]) - }, - }) - }) - - test("allows concurrent operations on different files", async () => { - await using tmp = await tmpdir() - const filepath1 = path.join(tmp.path, "file1.txt") - const filepath2 = path.join(tmp.path, "file2.txt") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - let started1 = false - let started2 = false - const hold = gate() - const ready = gate() - - const op1 = FileTime.withLock(filepath1, async () => { - started1 = true - ready.open() - await hold.wait - expect(started2).toBe(true) - }) - - await ready.wait - - const op2 = FileTime.withLock(filepath2, async () => { - started2 = true - hold.open() - }) - - await Promise.all([op1, op2]) - expect(started1).toBe(true) - expect(started2).toBe(true) - }, - }) - }) - - test("releases lock even if function throws", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await expect( - FileTime.withLock(filepath, async () => { - throw new Error("Test error") + yield* lock(file, () => + Effect.sync(() => { + hit = true + return "result" }), - ).rejects.toThrow("Test error") + ) - let executed = false - await FileTime.withLock(filepath, async () => { - executed = true - }) - expect(executed).toBe(true) - }, - }) - }) + expect(hit).toBe(true) + }), + ), + ) + + it.live("returns function result", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const file = path.join(dir, "file.txt") + const result = yield* lock(file, () => Effect.succeed("success")) + expect(result).toBe("success") + }), + ), + ) + + it.live("serializes concurrent operations on same file", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const file = path.join(dir, "file.txt") + const order: number[] = [] + const hold = yield* Deferred.make() + const ready = yield* Deferred.make() + + const one = yield* lock(file, () => + Effect.gen(function* () { + order.push(1) + yield* Deferred.succeed(ready, void 0) + yield* Deferred.await(hold) + order.push(2) + }), + ).pipe(Effect.forkScoped) + + yield* Deferred.await(ready) + + const two = yield* lock(file, () => + Effect.sync(() => { + order.push(3) + order.push(4) + }), + ).pipe(Effect.forkScoped) + + yield* Deferred.succeed(hold, void 0) + yield* Fiber.join(one) + yield* Fiber.join(two) + + expect(order).toEqual([1, 2, 3, 4]) + }), + ), + ) + + it.live("allows concurrent operations on different files", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const onefile = path.join(dir, "file1.txt") + const twofile = path.join(dir, "file2.txt") + let one = false + let two = false + const hold = yield* Deferred.make() + const ready = yield* Deferred.make() + + const a = yield* lock(onefile, () => + Effect.gen(function* () { + one = true + yield* Deferred.succeed(ready, void 0) + yield* Deferred.await(hold) + expect(two).toBe(true) + }), + ).pipe(Effect.forkScoped) + + yield* Deferred.await(ready) + + const b = yield* lock(twofile, () => + Effect.sync(() => { + two = true + }), + ).pipe(Effect.forkScoped) + + yield* Fiber.join(b) + yield* Deferred.succeed(hold, void 0) + yield* Fiber.join(a) + + expect(one).toBe(true) + expect(two).toBe(true) + }), + ), + ) + + it.live("releases lock even if function throws", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const file = path.join(dir, "file.txt") + const err = yield* fail(lock(file, () => Effect.die(new Error("Test error")))) + expect(err.message).toContain("Test error") + + let hit = false + yield* lock(file, () => + Effect.sync(() => { + hit = true + }), + ) + expect(hit).toBe(true) + }), + ), + ) }) describe("path normalization", () => { - test("read with forward slashes, assert with backslashes", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") - await touch(filepath, 1_000) + it.live("read with forward slashes, assert with backslashes", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const file = path.join(dir, "file.txt") + yield* put(file, "content") + yield* touch(file, 1_000) - const forwardSlash = filepath.replaceAll("\\", "/") + const forward = file.replaceAll("\\", "/") + yield* read(id, forward) + yield* check(id, file) + }), + ), + ) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await FileTime.read(sessionID, forwardSlash) - // assert with the native backslash path should still work - await FileTime.assert(sessionID, filepath) - }, - }) - }) + it.live("read with backslashes, assert with forward slashes", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const file = path.join(dir, "file.txt") + yield* put(file, "content") + yield* touch(file, 1_000) - test("read with backslashes, assert with forward slashes", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") - await touch(filepath, 1_000) + const forward = file.replaceAll("\\", "/") + yield* read(id, file) + yield* check(id, forward) + }), + ), + ) - const forwardSlash = filepath.replaceAll("\\", "/") + it.live("get returns timestamp regardless of slash direction", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const file = path.join(dir, "file.txt") + yield* put(file, "content") - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await FileTime.read(sessionID, filepath) - // assert with forward slashes should still work - await FileTime.assert(sessionID, forwardSlash) - }, - }) - }) + const forward = file.replaceAll("\\", "/") + yield* read(id, forward) - test("get returns timestamp regardless of slash direction", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") - - const forwardSlash = filepath.replaceAll("\\", "/") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await FileTime.read(sessionID, forwardSlash) - const result = await FileTime.get(sessionID, filepath) + const result = yield* get(id, file) expect(result).toBeInstanceOf(Date) - }, - }) - }) + }), + ), + ) - test("withLock serializes regardless of slash direction", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - - const forwardSlash = filepath.replaceAll("\\", "/") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { + it.live("withLock serializes regardless of slash direction", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const file = path.join(dir, "file.txt") + const forward = file.replaceAll("\\", "/") const order: number[] = [] - const hold = gate() - const ready = gate() + const hold = yield* Deferred.make() + const ready = yield* Deferred.make() - const op1 = FileTime.withLock(filepath, async () => { - order.push(1) - ready.open() - await hold.wait - order.push(2) - }) + const one = yield* lock(file, () => + Effect.gen(function* () { + order.push(1) + yield* Deferred.succeed(ready, void 0) + yield* Deferred.await(hold) + order.push(2) + }), + ).pipe(Effect.forkScoped) - await ready.wait + yield* Deferred.await(ready) - // Use forward-slash variant -- should still serialize against op1 - const op2 = FileTime.withLock(forwardSlash, async () => { - order.push(3) - order.push(4) - }) + const two = yield* lock(forward, () => + Effect.sync(() => { + order.push(3) + order.push(4) + }), + ).pipe(Effect.forkScoped) - hold.open() + yield* Deferred.succeed(hold, void 0) + yield* Fiber.join(one) + yield* Fiber.join(two) - await Promise.all([op1, op2]) expect(order).toEqual([1, 2, 3, 4]) - }, - }) - }) + }), + ), + ) }) describe("stat() Filesystem.stat pattern", () => { - test("reads file modification time via Filesystem.stat()", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") - await touch(filepath, 1_000) + it.live("reads file modification time via Filesystem.stat()", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const file = path.join(dir, "file.txt") + yield* put(file, "content") + yield* touch(file, 1_000) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await FileTime.read(sessionID, filepath) + yield* read(id, file) - const stats = Filesystem.stat(filepath) - expect(stats?.mtime).toBeInstanceOf(Date) - expect(stats!.mtime.getTime()).toBeGreaterThan(0) + const stat = Filesystem.stat(file) + expect(stat?.mtime).toBeInstanceOf(Date) + expect(stat!.mtime.getTime()).toBeGreaterThan(0) - await FileTime.assert(sessionID, filepath) - }, - }) - }) + yield* check(id, file) + }), + ), + ) - test("detects modification via stat mtime", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "original", "utf-8") - await touch(filepath, 1_000) + it.live("detects modification via stat mtime", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const file = path.join(dir, "file.txt") + yield* put(file, "original") + yield* touch(file, 1_000) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await FileTime.read(sessionID, filepath) + yield* read(id, file) - const originalStat = Filesystem.stat(filepath) + const first = Filesystem.stat(file) - await fs.writeFile(filepath, "modified", "utf-8") - await touch(filepath, 2_000) + yield* put(file, "modified") + yield* touch(file, 2_000) - const newStat = Filesystem.stat(filepath) - expect(newStat!.mtime.getTime()).toBeGreaterThan(originalStat!.mtime.getTime()) + const second = Filesystem.stat(file) + expect(second!.mtime.getTime()).toBeGreaterThan(first!.mtime.getTime()) - await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow() - }, - }) - }) + yield* fail(check(id, file)) + }), + ), + ) }) }) diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index 2224a80e6..0c8968d94 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -7,6 +7,7 @@ import { tmpdir } from "../fixture/fixture" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" import { FileWatcher } from "../../src/file/watcher" +import { Git } from "../../src/git" import { Instance } from "../../src/project/instance" // Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows) @@ -32,6 +33,7 @@ function withWatcher(directory: string, body: Effect.Effect) { fn: async () => { const layer: Layer.Layer = FileWatcher.layer.pipe( Layer.provide(Config.defaultLayer), + Layer.provide(Git.defaultLayer), Layer.provide(watcherConfigLayer), ) const rt = ManagedRuntime.make(layer) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 03713d879..797054354 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -2,7 +2,7 @@ import { $ } from "bun" import * as fs from "fs/promises" import os from "os" import path from "path" -import { Effect, ServiceMap } from "effect" +import { Effect, Context } from "effect" import type * as PlatformError from "effect/PlatformError" import type * as Scope from "effect/Scope" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" @@ -123,7 +123,7 @@ export function tmpdirScoped(options?: { git?: boolean; config?: Partial (self: Effect.Effect): Effect.Effect => - Effect.servicesWith((services: ServiceMap.ServiceMap) => + Effect.contextWith((services: Context.Context) => Effect.promise(async () => Instance.provide({ directory, diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index 7ddcc7733..26913222e 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -93,7 +93,6 @@ type Opts = { provider?: HostPluginApi["state"]["provider"] path?: HostPluginApi["state"]["path"] vcs?: HostPluginApi["state"]["vcs"] - workspace?: Partial session?: Partial part?: HostPluginApi["state"]["part"] lsp?: HostPluginApi["state"]["lsp"] @@ -277,15 +276,11 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { return opts.state?.provider ?? [] }, get path() { - return opts.state?.path ?? { state: "", config: "", worktree: "", directory: "" } + return opts.state?.path ?? { home: "", state: "", config: "", worktree: "", directory: "" } }, get vcs() { return opts.state?.vcs }, - workspace: { - list: opts.state?.workspace?.list ?? (() => []), - get: opts.state?.workspace?.get ?? (() => undefined), - }, session: { count: opts.state?.session?.count ?? (() => 0), diff: opts.state?.session?.diff ?? (() => []), diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts index 1b341d2f4..39826aad1 100644 --- a/packages/opencode/test/format/format.test.ts +++ b/packages/opencode/test/format/format.test.ts @@ -64,6 +64,46 @@ describe("Format", () => { ), ) + it.live("status() excludes uv when ruff is disabled", () => + provideTmpdirInstance( + () => + Format.Service.use((fmt) => + Effect.gen(function* () { + const statuses = yield* fmt.status() + expect(statuses.find((item) => item.name === "ruff")).toBeUndefined() + expect(statuses.find((item) => item.name === "uv")).toBeUndefined() + }), + ), + { + config: { + formatter: { + ruff: { disabled: true }, + }, + }, + }, + ), + ) + + it.live("status() excludes ruff when uv is disabled", () => + provideTmpdirInstance( + () => + Format.Service.use((fmt) => + Effect.gen(function* () { + const statuses = yield* fmt.status() + expect(statuses.find((item) => item.name === "ruff")).toBeUndefined() + expect(statuses.find((item) => item.name === "uv")).toBeUndefined() + }), + ), + { + config: { + formatter: { + uv: { disabled: true }, + }, + }, + }, + ), + ) + it.live("service initializes without error", () => provideTmpdirInstance(() => Format.Service.use(() => Effect.void))) it.live("status() initializes formatter state per directory", () => diff --git a/packages/opencode/test/installation/installation.test.ts b/packages/opencode/test/installation/installation.test.ts index b05c31029..2b04c3858 100644 --- a/packages/opencode/test/installation/installation.test.ts +++ b/packages/opencode/test/installation/installation.test.ts @@ -27,6 +27,7 @@ function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = all: Stream.empty, getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any, getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), }), ) }) diff --git a/packages/opencode/test/lib/llm-server.ts b/packages/opencode/test/lib/llm-server.ts index fbad6ac14..2e2a2ea89 100644 --- a/packages/opencode/test/lib/llm-server.ts +++ b/packages/opencode/test/lib/llm-server.ts @@ -1,6 +1,6 @@ import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node" import * as Http from "node:http" -import { Deferred, Effect, Layer, ServiceMap, Stream } from "effect" +import { Deferred, Effect, Layer, Context, Stream } from "effect" import * as HttpServer from "effect/unstable/http/HttpServer" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" @@ -650,7 +650,7 @@ namespace TestLLMServer { } } -export class TestLLMServer extends ServiceMap.Service()("@test/LLMServer") { +export class TestLLMServer extends Context.Service()("@test/LLMServer") { static readonly layer = Layer.effect( TestLLMServer, Effect.gen(function* () { diff --git a/packages/opencode/test/lsp/index.test.ts b/packages/opencode/test/lsp/index.test.ts index cfab72d83..7e514e39b 100644 --- a/packages/opencode/test/lsp/index.test.ts +++ b/packages/opencode/test/lsp/index.test.ts @@ -1,8 +1,6 @@ import { describe, expect, spyOn, test } from "bun:test" import path from "path" -import fs from "fs/promises" import * as Lsp from "../../src/lsp/index" -import * as launch from "../../src/lsp/launch" import { LSPServer } from "../../src/lsp/server" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" @@ -54,80 +52,4 @@ describe("lsp.spawn", () => { await Instance.disposeAll() } }) - - test("spawns builtin Typescript LSP with correct arguments", async () => { - await using tmp = await tmpdir() - - // Create dummy tsserver to satisfy Module.resolve - const tsdk = path.join(tmp.path, "node_modules", "typescript", "lib") - await fs.mkdir(tsdk, { recursive: true }) - await fs.writeFile(path.join(tsdk, "tsserver.js"), "") - - const spawnSpy = spyOn(launch, "spawn").mockImplementation( - () => - ({ - stdin: {}, - stdout: {}, - stderr: {}, - on: () => {}, - kill: () => {}, - }) as any, - ) - - try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await LSPServer.Typescript.spawn(tmp.path) - }, - }) - - expect(spawnSpy).toHaveBeenCalled() - const args = spawnSpy.mock.calls[0][1] as string[] - - expect(args).toContain("--tsserver-path") - expect(args).toContain("--tsserver-log-verbosity") - expect(args).toContain("off") - } finally { - spawnSpy.mockRestore() - } - }) - - test("spawns builtin Typescript LSP with --ignore-node-modules if no config is found", async () => { - await using tmp = await tmpdir() - - // Create dummy tsserver to satisfy Module.resolve - const tsdk = path.join(tmp.path, "node_modules", "typescript", "lib") - await fs.mkdir(tsdk, { recursive: true }) - await fs.writeFile(path.join(tsdk, "tsserver.js"), "") - - // NO tsconfig.json or jsconfig.json created here - - const spawnSpy = spyOn(launch, "spawn").mockImplementation( - () => - ({ - stdin: {}, - stdout: {}, - stderr: {}, - on: () => {}, - kill: () => {}, - }) as any, - ) - - try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await LSPServer.Typescript.spawn(tmp.path) - }, - }) - - expect(spawnSpy).toHaveBeenCalled() - const args = spawnSpy.mock.calls[0][1] as string[] - - expect(args).toContain("--ignore-node-modules") - } finally { - spawnSpy.mockRestore() - } - }) }) diff --git a/packages/opencode/test/mcp/oauth-callback.test.ts b/packages/opencode/test/mcp/oauth-callback.test.ts new file mode 100644 index 000000000..58a4fa8c8 --- /dev/null +++ b/packages/opencode/test/mcp/oauth-callback.test.ts @@ -0,0 +1,34 @@ +import { test, expect, describe, afterEach } from "bun:test" +import { McpOAuthCallback } from "../../src/mcp/oauth-callback" +import { parseRedirectUri } from "../../src/mcp/oauth-provider" + +describe("parseRedirectUri", () => { + test("returns defaults when no URI provided", () => { + const result = parseRedirectUri() + expect(result.port).toBe(19876) + expect(result.path).toBe("/mcp/oauth/callback") + }) + + test("parses port and path from URI", () => { + const result = parseRedirectUri("http://127.0.0.1:8080/oauth/callback") + expect(result.port).toBe(8080) + expect(result.path).toBe("/oauth/callback") + }) + + test("returns defaults for invalid URI", () => { + const result = parseRedirectUri("not-a-valid-url") + expect(result.port).toBe(19876) + expect(result.path).toBe("/mcp/oauth/callback") + }) +}) + +describe("McpOAuthCallback.ensureRunning", () => { + afterEach(async () => { + await McpOAuthCallback.stop() + }) + + test("starts server with custom redirectUri port and path", async () => { + await McpOAuthCallback.ensureRunning("http://127.0.0.1:18000/custom/callback") + expect(McpOAuthCallback.isRunning()).toBe(true) + }) +}) diff --git a/packages/opencode/test/memory/abort-leak-webfetch.ts b/packages/opencode/test/memory/abort-leak-webfetch.ts new file mode 100644 index 000000000..1286d5f0b --- /dev/null +++ b/packages/opencode/test/memory/abort-leak-webfetch.ts @@ -0,0 +1,49 @@ +import { abortAfterAny } from "../../src/util/abort" + +const MB = 1024 * 1024 +const ITERATIONS = 50 + +const heap = () => { + Bun.gc(true) + return process.memoryUsage().heapUsed / MB +} + +const server = Bun.serve({ + port: 0, + fetch() { + return new Response("hello from local", { + headers: { + "content-type": "text/plain", + }, + }) + }, +}) + +const url = `http://127.0.0.1:${server.port}` + +async function run() { + const { signal, clearTimeout } = abortAfterAny(30000, new AbortController().signal) + try { + const response = await fetch(url, { signal }) + await response.text() + } finally { + clearTimeout() + } +} + +try { + await run() + Bun.sleepSync(100) + const baseline = heap() + + for (let i = 0; i < ITERATIONS; i++) { + await run() + } + + Bun.sleepSync(100) + const after = heap() + process.stdout.write(JSON.stringify({ baseline, after, growth: after - baseline })) +} finally { + server.stop(true) + process.exit(0) +} diff --git a/packages/opencode/test/memory/abort-leak.test.ts b/packages/opencode/test/memory/abort-leak.test.ts index eebb651a5..d30ad45e4 100644 --- a/packages/opencode/test/memory/abort-leak.test.ts +++ b/packages/opencode/test/memory/abort-leak.test.ts @@ -1,21 +1,8 @@ import { describe, test, expect } from "bun:test" import path from "path" -import { Instance } from "../../src/project/instance" -import { WebFetchTool } from "../../src/tool/webfetch" -import { SessionID, MessageID } from "../../src/session/schema" -const projectRoot = path.join(__dirname, "../..") - -const ctx = { - sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), - callID: "", - agent: "build", - abort: new AbortController().signal, - messages: [], - metadata: () => {}, - ask: async () => {}, -} +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 @@ -27,35 +14,38 @@ const getHeapMB = () => { describe("memory: abort controller leak", () => { test("webfetch does not leak memory over many invocations", async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const tool = await WebFetchTool.init() - - // Warm up - await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {}) - - Bun.gc(true) - const baseline = getHeapMB() - - // Run many fetches - for (let i = 0; i < ITERATIONS; i++) { - await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {}) - } - - Bun.gc(true) - const after = getHeapMB() - const growth = after - baseline - - console.log(`Baseline: ${baseline.toFixed(2)} MB`) - console.log(`After ${ITERATIONS} fetches: ${after.toFixed(2)} MB`) - console.log(`Growth: ${growth.toFixed(2)} MB`) - - // Memory growth should be minimal - less than 1MB per 10 requests - // With the old closure pattern, this would grow ~0.5MB per request - expect(growth).toBeLessThan(ITERATIONS / 10) - }, + // 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 () => { diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index 6b7708382..36a02058e 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -1,6 +1,7 @@ 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" @@ -39,14 +40,18 @@ describe("plugin.auth-override", () => { const methods = await Instance.provide({ directory: tmp.path, fn: async () => { - return ProviderAuth.methods() + return Effect.runPromise( + ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(ProviderAuth.defaultLayer)), + ) }, }) const plainMethods = await Instance.provide({ directory: plain.path, fn: async () => { - return ProviderAuth.methods() + return Effect.runPromise( + ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(ProviderAuth.defaultLayer)), + ) }, }) diff --git a/packages/opencode/test/plugin/github-copilot-models.test.ts b/packages/opencode/test/plugin/github-copilot-models.test.ts index 78fe40aea..0b67588a7 100644 --- a/packages/opencode/test/plugin/github-copilot-models.test.ts +++ b/packages/opencode/test/plugin/github-copilot-models.test.ts @@ -1,5 +1,6 @@ import { afterEach, expect, mock, test } from "bun:test" import { CopilotModels } from "@/plugin/github-copilot/models" +import { CopilotAuthPlugin } from "@/plugin/github-copilot/copilot" const originalFetch = globalThis.fetch @@ -115,3 +116,45 @@ test("preserves temperature support from existing provider models", async () => expect(models["gpt-4o"].capabilities.temperature).toBe(true) expect(models["brand-new"].capabilities.temperature).toBe(true) }) + +test("remaps fallback oauth model urls to the enterprise host", async () => { + globalThis.fetch = mock(() => Promise.reject(new Error("timeout"))) as unknown as typeof fetch + + const hooks = await CopilotAuthPlugin({ + client: {} as never, + project: {} as never, + directory: "", + worktree: "", + serverUrl: new URL("https://example.com"), + $: {} as never, + }) + + const models = await hooks.provider!.models!( + { + id: "github-copilot", + models: { + claude: { + id: "claude", + providerID: "github-copilot", + api: { + id: "claude-sonnet-4.5", + url: "https://api.githubcopilot.com/v1", + npm: "@ai-sdk/anthropic", + }, + }, + }, + } as never, + { + auth: { + type: "oauth", + refresh: "token", + access: "token", + expires: Date.now() + 60_000, + enterpriseUrl: "ghe.example.com", + } as never, + }, + ) + + expect(models.claude.api.url).toBe("https://copilot-api.ghe.example.com") + expect(models.claude.api.npm).toBe("@ai-sdk/github-copilot") +}) diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index c01a02ef4..32b6c601d 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -13,8 +13,6 @@ const { PluginLoader } = await import("../../src/plugin/loader") const { readPackageThemes } = await import("../../src/plugin/shared") const { Instance } = await import("../../src/project/instance") const { Npm } = await import("../../src/npm") -const { Bus } = await import("../../src/bus") -const { Session } = await import("../../src/session") afterAll(() => { if (disableDefault === undefined) { @@ -37,27 +35,6 @@ async function load(dir: string) { }) } -async function errs(dir: string) { - return Instance.provide({ - directory: dir, - fn: async () => { - const errors: string[] = [] - const off = Bus.subscribe(Session.Event.Error, (evt) => { - const error = evt.properties.error - if (!error || typeof error !== "object") return - if (!("data" in error)) return - if (!error.data || typeof error.data !== "object") return - if (!("message" in error.data)) return - if (typeof error.data.message !== "string") return - errors.push(error.data.message) - }) - await Plugin.list() - off() - return errors - }, - }) -} - describe("plugin.loader.shared", () => { test("loads a file:// plugin function export", async () => { await using tmp = await tmpdir({ @@ -184,14 +161,13 @@ describe("plugin.loader.shared", () => { }, }) - const errors = await errs(tmp.path) + await load(tmp.path) const called = await Bun.file(tmp.extra.mark) .text() .then(() => true) .catch(() => false) expect(called).toBe(false) - expect(errors.some((x) => x.includes("must export id"))).toBe(true) }) test("rejects v1 plugin that exports server and tui together", async () => { @@ -223,14 +199,13 @@ describe("plugin.loader.shared", () => { }, }) - const errors = await errs(tmp.path) + await load(tmp.path) const called = await Bun.file(tmp.extra.mark) .text() .then(() => true) .catch(() => false) expect(called).toBe(false) - expect(errors.some((x) => x.includes("either server() or tui(), not both"))).toBe(true) }) test("resolves npm plugin specs with explicit and default versions", async () => { @@ -383,8 +358,7 @@ describe("plugin.loader.shared", () => { const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) try { - const errors = await errs(tmp.path) - expect(errors).toHaveLength(0) + await load(tmp.path) expect(await Bun.file(tmp.extra.mark).text()).toBe("called") } finally { install.mockRestore() @@ -436,8 +410,7 @@ describe("plugin.loader.shared", () => { const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) try { - const errors = await errs(tmp.path) - expect(errors).toHaveLength(0) + await load(tmp.path) expect(await Bun.file(tmp.extra.mark).text()).toBe("called") } finally { install.mockRestore() @@ -482,14 +455,13 @@ describe("plugin.loader.shared", () => { const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) try { - const errors = await errs(tmp.path) + await load(tmp.path) const called = await Bun.file(tmp.extra.mark) .text() .then(() => true) .catch(() => false) expect(called).toBe(false) - expect(errors).toHaveLength(0) } finally { install.mockRestore() } @@ -546,13 +518,12 @@ describe("plugin.loader.shared", () => { const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) try { - const errors = await errs(tmp.path) + await load(tmp.path) const called = await Bun.file(tmp.extra.mark) .text() .then(() => true) .catch(() => false) expect(called).toBe(false) - expect(errors.some((x) => x.includes("outside plugin directory"))).toBe(true) } finally { install.mockRestore() } @@ -588,30 +559,49 @@ describe("plugin.loader.shared", () => { } }) - test("publishes session.error when install fails", async () => { + test("skips broken plugin when install fails", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["broken-plugin@9.9.9"] }, null, 2)) + const ok = path.join(dir, "ok.ts") + const mark = path.join(dir, "ok.txt") + await Bun.write( + ok, + [ + "export default {", + ' id: "demo.ok",', + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "ok")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ plugin: ["broken-plugin@9.9.9", pathToFileURL(ok).href] }, null, 2), + ) + return { mark } }, }) const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom")) try { - const errors = await errs(tmp.path) - - expect(errors.some((x) => x.includes("Failed to install plugin broken-plugin@9.9.9") && x.includes("boom"))).toBe( - true, - ) + await load(tmp.path) + expect(install).toHaveBeenCalledWith("broken-plugin@9.9.9") + expect(await Bun.file(tmp.extra.mark).text()).toBe("ok") } finally { install.mockRestore() } }) - test("publishes session.error when plugin init throws", async () => { + test("continues loading plugins when plugin init throws", async () => { await using tmp = await tmpdir({ init: async (dir) => { const file = pathToFileURL(path.join(dir, "throws.ts")).href + const ok = pathToFileURL(path.join(dir, "ok.ts")).href + const mark = path.join(dir, "ok.txt") await Bun.write( path.join(dir, "throws.ts"), [ @@ -624,51 +614,91 @@ describe("plugin.loader.shared", () => { "", ].join("\n"), ) + await Bun.write( + path.join(dir, "ok.ts"), + [ + "export default {", + ' id: "demo.ok",', + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "ok")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) - await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2)) + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2)) - return { file } + return { mark } }, }) - const errors = await errs(tmp.path) - - expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}: explode`))).toBe(true) + await load(tmp.path) + expect(await Bun.file(tmp.extra.mark).text()).toBe("ok") }) - test("publishes session.error when plugin module has invalid export", async () => { + test("continues loading plugins when plugin module has invalid export", async () => { await using tmp = await tmpdir({ init: async (dir) => { const file = pathToFileURL(path.join(dir, "invalid.ts")).href + const ok = pathToFileURL(path.join(dir, "ok.ts")).href + const mark = path.join(dir, "ok.txt") await Bun.write( path.join(dir, "invalid.ts"), ["export default {", ' id: "demo.invalid",', " nope: true,", "}", ""].join("\n"), ) + await Bun.write( + path.join(dir, "ok.ts"), + [ + "export default {", + ' id: "demo.ok",', + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "ok")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) - await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2)) + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2)) - return { file } + return { mark } }, }) - const errors = await errs(tmp.path) - - expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}`))).toBe(true) + await load(tmp.path) + expect(await Bun.file(tmp.extra.mark).text()).toBe("ok") }) - test("publishes session.error when plugin import fails", async () => { + test("continues loading plugins when plugin import fails", async () => { await using tmp = await tmpdir({ init: async (dir) => { const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href - await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing] }, null, 2)) + const ok = pathToFileURL(path.join(dir, "ok.ts")).href + const mark = path.join(dir, "ok.txt") + await Bun.write( + path.join(dir, "ok.ts"), + [ + "export default {", + ' id: "demo.ok",', + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "ok")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing, ok] }, null, 2)) - return { missing } + return { mark } }, }) - const errors = await errs(tmp.path) - - expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.missing}`))).toBe(true) + await load(tmp.path) + expect(await Bun.file(tmp.extra.mark).text()).toBe("ok") }) test("loads object plugin via plugin.server", async () => { diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 988ae2742..93d97e6a4 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -41,6 +41,7 @@ function mockGitFailure(failArg: string) { all: Stream.empty, getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any, getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), }) } return yield* real.spawn(command) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index a327f65fa..1610902af 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" import { tmpdir } from "../fixture/fixture" +import { AppRuntime } from "../../src/effect/app-runtime" import { FileWatcher } from "../../src/file/watcher" import { Instance } from "../../src/project/instance" import { GlobalBus } from "../../src/bus/global" @@ -19,7 +20,7 @@ async function withVcs(directory: string, body: () => Promise) { return Instance.provide({ directory, fn: async () => { - FileWatcher.init() + void AppRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init())) Vcs.init() await Bun.sleep(500) await body() diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 88e9ea64c..9cadc391a 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from "../fixture/fixture" import { Global } from "../../src/global" import { Instance } from "../../src/project/instance" import { Plugin } from "../../src/plugin/index" +import { ModelsDev } from "../../src/provider/models" import { Provider } from "../../src/provider/provider" import { ProviderID, ModelID } from "../../src/provider/schema" import { Filesystem } from "../../src/util/filesystem" @@ -1823,6 +1824,73 @@ test("custom model inherits api.url from models.dev provider", async () => { }) }) +test("mode cost preserves over-200k pricing from base model", () => { + const provider = { + id: "openai", + name: "OpenAI", + env: [], + api: "https://api.openai.com/v1", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + family: "gpt", + release_date: "2026-03-05", + attachment: true, + reasoning: true, + temperature: false, + tool_call: true, + cost: { + input: 2.5, + output: 15, + cache_read: 0.25, + context_over_200k: { + input: 5, + output: 22.5, + cache_read: 0.5, + }, + }, + limit: { + context: 1_050_000, + input: 922_000, + output: 128_000, + }, + experimental: { + modes: { + fast: { + cost: { + input: 5, + output: 30, + cache_read: 0.5, + }, + provider: { + body: { + service_tier: "priority", + }, + }, + }, + }, + }, + }, + }, + } as ModelsDev.Provider + + const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"] + expect(model.cost.input).toEqual(5) + expect(model.cost.output).toEqual(30) + expect(model.cost.cache.read).toEqual(0.5) + expect(model.cost.cache.write).toEqual(0) + expect(model.options["serviceTier"]).toEqual("priority") + expect(model.cost.experimentalOver200K).toEqual({ + input: 5, + output: 22.5, + cache: { + read: 0.5, + write: 0, + }, + }) +}) + test("model variants are generated for reasoning models", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 0aee396f4..3a001e275 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -104,6 +104,58 @@ describe("ProviderTransform.options - setCacheKey", () => { }) }) +describe("ProviderTransform.options - zai/zhipuai thinking", () => { + const sessionID = "test-session-123" + + const createModel = (providerID: string) => + ({ + id: `${providerID}/glm-4.6`, + providerID, + api: { + id: "glm-4.6", + url: "https://open.bigmodel.cn/api/paas/v4", + npm: "@ai-sdk/openai-compatible", + }, + name: "GLM 4.6", + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0.001, + output: 0.002, + cache: { read: 0.0001, write: 0.0002 }, + }, + limit: { + context: 128000, + output: 8192, + }, + status: "active", + options: {}, + headers: {}, + }) as any + + for (const providerID of ["zai-coding-plan", "zai", "zhipuai-coding-plan", "zhipuai"]) { + test(`${providerID} should set thinking cfg`, () => { + const result = ProviderTransform.options({ + model: createModel(providerID), + sessionID, + providerOptions: {}, + }) + + expect(result.thinking).toEqual({ + type: "enabled", + clear_thinking: false, + }) + }) + } +}) + describe("ProviderTransform.options - google thinkingConfig gating", () => { const sessionID = "test-session-123" diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index adfeda395..7c101ce28 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -4,6 +4,17 @@ import { Instance } from "../../src/project/instance" import { QuestionID } from "../../src/question/schema" import { tmpdir } from "../fixture/fixture" import { SessionID } from "../../src/session/schema" +import { AppRuntime } from "../../src/effect/app-runtime" + +const ask = (input: { sessionID: SessionID; questions: Question.Info[]; tool?: { messageID: any; callID: string } }) => + AppRuntime.runPromise(Question.Service.use((svc) => svc.ask(input))) + +const list = () => AppRuntime.runPromise(Question.Service.use((svc) => svc.list())) + +const reply = (input: { requestID: QuestionID; answers: Question.Answer[] }) => + AppRuntime.runPromise(Question.Service.use((svc) => svc.reply(input))) + +const reject = (id: QuestionID) => AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(id))) afterEach(async () => { await Instance.disposeAll() @@ -11,9 +22,9 @@ afterEach(async () => { /** Reject all pending questions so dangling Deferred fibers don't hang the test. */ async function rejectAll() { - const pending = await Question.list() + const pending = await list() for (const req of pending) { - await Question.reject(req.id) + await reject(req.id) } } @@ -22,7 +33,7 @@ test("ask - returns pending promise", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const promise = Question.ask({ + const promise = ask({ sessionID: SessionID.make("ses_test"), questions: [ { @@ -58,16 +69,16 @@ test("ask - adds to pending list", async () => { }, ] - const askPromise = Question.ask({ + const promise = ask({ sessionID: SessionID.make("ses_test"), questions, }) - const pending = await Question.list() + const pending = await list() expect(pending.length).toBe(1) expect(pending[0].questions).toEqual(questions) await rejectAll() - await askPromise.catch(() => {}) + await promise.catch(() => {}) }, }) }) @@ -90,20 +101,20 @@ test("reply - resolves the pending ask with answers", async () => { }, ] - const askPromise = Question.ask({ + const promise = ask({ sessionID: SessionID.make("ses_test"), questions, }) - const pending = await Question.list() + const pending = await list() const requestID = pending[0].id - await Question.reply({ + await reply({ requestID, answers: [["Option 1"]], }) - const answers = await askPromise + const answers = await promise expect(answers).toEqual([["Option 1"]]) }, }) @@ -114,7 +125,7 @@ test("reply - removes from pending list", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const askPromise = Question.ask({ + const promise = ask({ sessionID: SessionID.make("ses_test"), questions: [ { @@ -128,17 +139,17 @@ test("reply - removes from pending list", async () => { ], }) - const pending = await Question.list() + const pending = await list() expect(pending.length).toBe(1) - await Question.reply({ + await reply({ requestID: pending[0].id, answers: [["Option 1"]], }) - await askPromise + await promise - const pendingAfter = await Question.list() - expect(pendingAfter.length).toBe(0) + const after = await list() + expect(after.length).toBe(0) }, }) }) @@ -148,7 +159,7 @@ test("reply - does nothing for unknown requestID", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await Question.reply({ + await reply({ requestID: QuestionID.make("que_unknown"), answers: [["Option 1"]], }) @@ -164,7 +175,7 @@ test("reject - throws RejectedError", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const askPromise = Question.ask({ + const promise = ask({ sessionID: SessionID.make("ses_test"), questions: [ { @@ -178,10 +189,10 @@ test("reject - throws RejectedError", async () => { ], }) - const pending = await Question.list() - await Question.reject(pending[0].id) + const pending = await list() + await reject(pending[0].id) - await expect(askPromise).rejects.toBeInstanceOf(Question.RejectedError) + await expect(promise).rejects.toBeInstanceOf(Question.RejectedError) }, }) }) @@ -191,7 +202,7 @@ test("reject - removes from pending list", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const askPromise = Question.ask({ + const promise = ask({ sessionID: SessionID.make("ses_test"), questions: [ { @@ -205,14 +216,14 @@ test("reject - removes from pending list", async () => { ], }) - const pending = await Question.list() + const pending = await list() expect(pending.length).toBe(1) - await Question.reject(pending[0].id) - askPromise.catch(() => {}) // Ignore rejection + await reject(pending[0].id) + promise.catch(() => {}) // Ignore rejection - const pendingAfter = await Question.list() - expect(pendingAfter.length).toBe(0) + const after = await list() + expect(after.length).toBe(0) }, }) }) @@ -222,7 +233,7 @@ test("reject - does nothing for unknown requestID", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await Question.reject(QuestionID.make("que_unknown")) + await reject(QuestionID.make("que_unknown")) // Should not throw }, }) @@ -254,19 +265,19 @@ test("ask - handles multiple questions", async () => { }, ] - const askPromise = Question.ask({ + const promise = ask({ sessionID: SessionID.make("ses_test"), questions, }) - const pending = await Question.list() + const pending = await list() - await Question.reply({ + await reply({ requestID: pending[0].id, answers: [["Build"], ["Dev"]], }) - const answers = await askPromise + const answers = await promise expect(answers).toEqual([["Build"], ["Dev"]]) }, }) @@ -279,7 +290,7 @@ test("list - returns all pending requests", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const p1 = Question.ask({ + const p1 = ask({ sessionID: SessionID.make("ses_test1"), questions: [ { @@ -290,7 +301,7 @@ test("list - returns all pending requests", async () => { ], }) - const p2 = Question.ask({ + const p2 = ask({ sessionID: SessionID.make("ses_test2"), questions: [ { @@ -301,7 +312,7 @@ test("list - returns all pending requests", async () => { ], }) - const pending = await Question.list() + const pending = await list() expect(pending.length).toBe(2) await rejectAll() p1.catch(() => {}) @@ -315,7 +326,7 @@ test("list - returns empty when no pending", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const pending = await Question.list() + const pending = await list() expect(pending.length).toBe(0) }, }) @@ -328,7 +339,7 @@ test("questions stay isolated by directory", async () => { const p1 = Instance.provide({ directory: one.path, fn: () => - Question.ask({ + ask({ sessionID: SessionID.make("ses_one"), questions: [ { @@ -343,7 +354,7 @@ test("questions stay isolated by directory", async () => { const p2 = Instance.provide({ directory: two.path, fn: () => - Question.ask({ + ask({ sessionID: SessionID.make("ses_two"), questions: [ { @@ -357,11 +368,11 @@ test("questions stay isolated by directory", async () => { const onePending = await Instance.provide({ directory: one.path, - fn: () => Question.list(), + fn: () => list(), }) const twoPending = await Instance.provide({ directory: two.path, - fn: () => Question.list(), + fn: () => list(), }) expect(onePending.length).toBe(1) @@ -371,11 +382,11 @@ test("questions stay isolated by directory", async () => { await Instance.provide({ directory: one.path, - fn: () => Question.reject(onePending[0].id), + fn: () => reject(onePending[0].id), }) await Instance.provide({ directory: two.path, - fn: () => Question.reject(twoPending[0].id), + fn: () => reject(twoPending[0].id), }) await p1.catch(() => {}) @@ -385,10 +396,10 @@ test("questions stay isolated by directory", async () => { test("pending question rejects on instance dispose", async () => { await using tmp = await tmpdir({ git: true }) - const ask = Instance.provide({ + const pending = Instance.provide({ directory: tmp.path, fn: () => { - return Question.ask({ + return ask({ sessionID: SessionID.make("ses_dispose"), questions: [ { @@ -400,7 +411,7 @@ test("pending question rejects on instance dispose", async () => { }) }, }) - const result = ask.then( + const result = pending.then( () => "resolved" as const, (err) => err, ) @@ -408,8 +419,8 @@ test("pending question rejects on instance dispose", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const pending = await Question.list() - expect(pending).toHaveLength(1) + const items = await list() + expect(items).toHaveLength(1) await Instance.dispose() }, }) @@ -420,10 +431,10 @@ test("pending question rejects on instance dispose", async () => { test("pending question rejects on instance reload", async () => { await using tmp = await tmpdir({ git: true }) - const ask = Instance.provide({ + const pending = Instance.provide({ directory: tmp.path, fn: () => { - return Question.ask({ + return ask({ sessionID: SessionID.make("ses_reload"), questions: [ { @@ -435,7 +446,7 @@ test("pending question rejects on instance reload", async () => { }) }, }) - const result = ask.then( + const result = pending.then( () => "resolved" as const, (err) => err, ) @@ -443,8 +454,8 @@ test("pending question rejects on instance reload", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const pending = await Question.list() - expect(pending).toHaveLength(1) + const items = await list() + expect(items).toHaveLength(1) await Instance.reload({ directory: tmp.path }) }, }) diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts index 855997775..eca562a0f 100644 --- a/packages/opencode/test/server/project-init-git.test.ts +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -19,7 +19,7 @@ afterEach(async () => { describe("project.initGit endpoint", () => { test("initializes git and reloads immediately", async () => { await using tmp = await tmpdir() - const app = Server.Default() + const app = Server.Default().app const seen: { directory?: string; payload: { type: string } }[] = [] const fn = (evt: { directory?: string; payload: { type: string } }) => { seen.push(evt) @@ -76,7 +76,7 @@ describe("project.initGit endpoint", () => { test("does not reload when the project is already git", async () => { await using tmp = await tmpdir({ git: true }) - const app = Server.Default() + const app = Server.Default().app const seen: { directory?: string; payload: { type: string } }[] = [] const fn = (evt: { directory?: string; payload: { type: string } }) => { seen.push(evt) diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index e6dba676c..29032c69c 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -1,9 +1,8 @@ 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 { Log } from "../../src/util/log" import { tmpdir } from "../fixture/fixture" @@ -15,25 +14,6 @@ afterEach(async () => { await Instance.disposeAll() }) -async function user(sessionID: SessionID, text: string) { - const msg = await Session.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID, - agent: "build", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, - time: { created: Date.now() }, - }) - await Session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: msg.id, - type: "text", - text, - }) - return msg -} - describe("session action routes", () => { test("abort route calls SessionPrompt.cancel", async () => { await using tmp = await tmpdir({ git: true }) @@ -42,11 +22,9 @@ describe("session action routes", () => { fn: async () => { const session = await Session.create({}) const cancel = spyOn(SessionPrompt, "cancel").mockResolvedValue() - const app = Server.Default() + 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) @@ -56,28 +34,4 @@ describe("session action routes", () => { }, }) }) - - test("delete message route returns 400 when session is busy", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const session = await Session.create({}) - const msg = await user(session.id, "hello") - const busy = spyOn(SessionPrompt, "assertNotBusy").mockRejectedValue(new Session.BusyError(session.id)) - const remove = spyOn(Session, "removeMessage").mockResolvedValue(msg.id) - const app = Server.Default() - - 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) - }, - }) - }) }) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index 89e6fba5c..fac336837 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -60,7 +60,7 @@ describe("session messages endpoint", () => { fn: async () => { const session = await Session.create({}) const ids = await fill(session.id, 5) - const app = Server.Default() + const app = Server.Default().app const a = await app.request(`/session/${session.id}/message?limit=2`) expect(a.status).toBe(200) @@ -89,7 +89,7 @@ describe("session messages endpoint", () => { fn: async () => { const session = await Session.create({}) const ids = await fill(session.id, 3) - const app = Server.Default() + const app = Server.Default().app const res = await app.request(`/session/${session.id}/message`) expect(res.status).toBe(200) @@ -109,7 +109,7 @@ describe("session messages endpoint", () => { directory: tmp.path, fn: async () => { const session = await Session.create({}) - const app = Server.Default() + const app = Server.Default().app const bad = await app.request(`/session/${session.id}/message?limit=2&before=bad`) expect(bad.status).toBe(400) @@ -131,7 +131,7 @@ describe("session messages endpoint", () => { fn: async () => { const session = await Session.create({}) await fill(session.id, 520) - const app = Server.Default() + const app = Server.Default().app const res = await app.request(`/session/${session.id}/message?limit=510`) expect(res.status).toBe(200) @@ -147,7 +147,7 @@ describe("session messages endpoint", () => { describe("session.prompt_async error handling", () => { test("prompt_async route has error handler for detached prompt call", async () => { - const src = await Bun.file(new URL("../../src/server/routes/session.ts", import.meta.url)).text() + const src = await Bun.file(new URL("../../src/server/instance/session.ts", import.meta.url)).text() const start = src.indexOf('"/:sessionID/prompt_async"') const end = src.indexOf('"/:sessionID/command"', start) expect(start).toBeGreaterThan(-1) diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts index 345b43146..7558b4a6b 100644 --- a/packages/opencode/test/server/session-select.test.ts +++ b/packages/opencode/test/server/session-select.test.ts @@ -21,7 +21,7 @@ describe("tui.selectSession endpoint", () => { const session = await Session.create({}) // #when - const app = Server.Default() + const app = Server.Default().app const response = await app.request("/tui/select-session", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -47,7 +47,7 @@ describe("tui.selectSession endpoint", () => { const nonExistentSessionID = "ses_nonexistent123" // #when - const app = Server.Default() + const app = Server.Default().app const response = await app.request("/tui/select-session", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -69,7 +69,7 @@ describe("tui.selectSession endpoint", () => { const invalidSessionID = "invalid_session_id" // #when - const app = Server.Default() + const app = Server.Default().app const response = await app.request("/tui/select-session", { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 799bb3e2a..76a83c34d 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -139,18 +139,8 @@ function fake( get message() { return msg }, - abort: Effect.fn("TestSessionProcessor.abort")(() => Effect.void), - partFromToolCall() { - return { - id: PartID.ascending(), - messageID: msg.id, - sessionID: msg.sessionID, - type: "tool", - callID: "fake", - tool: "fake", - state: { status: "pending", input: {}, raw: "" }, - } - }, + updateToolCall: Effect.fn("TestSessionProcessor.updateToolCall")(() => Effect.succeed(undefined)), + completeToolCall: Effect.fn("TestSessionProcessor.completeToolCall")(() => Effect.void), process: Effect.fn("TestSessionProcessor.process")(() => Effect.succeed(result)), } satisfies SessionProcessorModule.SessionProcessor.Handle } diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index a8c25c6f0..4ba3b78e4 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" import path from "path" +import { Effect } from "effect" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instruction } from "../../src/session/instruction" import type { MessageV2 } from "../../src/session/message-v2" @@ -8,6 +9,9 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema" import { Global } from "../../src/global" import { tmpdir } from "../fixture/fixture" +const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(Instruction.defaultLayer))) + function loaded(filepath: string): MessageV2.WithParts[] { const sessionID = SessionID.make("session-loaded-1") const messageID = MessageID.make("message-loaded-1") @@ -57,17 +61,22 @@ describe("Instruction.resolve", () => { }) await Instance.provide({ directory: tmp.path, - fn: async () => { - const system = await Instruction.systemPaths() - expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true) + fn: () => + run( + Instruction.Service.use((svc) => + Effect.gen(function* () { + const system = yield* svc.systemPaths() + expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true) - const results = await Instruction.resolve( - [], - path.join(tmp.path, "src", "file.ts"), - MessageID.make("message-test-1"), - ) - expect(results).toEqual([]) - }, + const results = yield* svc.resolve( + [], + path.join(tmp.path, "src", "file.ts"), + MessageID.make("message-test-1"), + ) + expect(results).toEqual([]) + }), + ), + ), }) }) @@ -80,18 +89,23 @@ describe("Instruction.resolve", () => { }) await Instance.provide({ directory: tmp.path, - fn: async () => { - const system = await Instruction.systemPaths() - expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false) + fn: () => + run( + Instruction.Service.use((svc) => + Effect.gen(function* () { + const system = yield* svc.systemPaths() + expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false) - const results = await Instruction.resolve( - [], - path.join(tmp.path, "subdir", "nested", "file.ts"), - MessageID.make("message-test-2"), - ) - expect(results.length).toBe(1) - expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) - }, + const results = yield* svc.resolve( + [], + path.join(tmp.path, "subdir", "nested", "file.ts"), + MessageID.make("message-test-2"), + ) + expect(results.length).toBe(1) + expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) + }), + ), + ), }) }) @@ -104,14 +118,19 @@ describe("Instruction.resolve", () => { }) await Instance.provide({ directory: tmp.path, - fn: async () => { - const filepath = path.join(tmp.path, "subdir", "AGENTS.md") - const system = await Instruction.systemPaths() - expect(system.has(filepath)).toBe(false) + fn: () => + run( + Instruction.Service.use((svc) => + Effect.gen(function* () { + const filepath = path.join(tmp.path, "subdir", "AGENTS.md") + const system = yield* svc.systemPaths() + expect(system.has(filepath)).toBe(false) - const results = await Instruction.resolve([], filepath, MessageID.make("message-test-3")) - expect(results).toEqual([]) - }, + const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3")) + expect(results).toEqual([]) + }), + ), + ), }) }) @@ -124,17 +143,22 @@ describe("Instruction.resolve", () => { }) await Instance.provide({ directory: tmp.path, - fn: async () => { - const filepath = path.join(tmp.path, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-1") + fn: () => + run( + Instruction.Service.use((svc) => + Effect.gen(function* () { + const filepath = path.join(tmp.path, "subdir", "nested", "file.ts") + const id = MessageID.make("message-claim-1") - const first = await Instruction.resolve([], filepath, id) - const second = await Instruction.resolve([], filepath, id) + const first = yield* svc.resolve([], filepath, id) + const second = yield* svc.resolve([], filepath, id) - expect(first).toHaveLength(1) - expect(first[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) - expect(second).toEqual([]) - }, + expect(first).toHaveLength(1) + expect(first[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) + expect(second).toEqual([]) + }), + ), + ), }) }) @@ -147,18 +171,23 @@ describe("Instruction.resolve", () => { }) await Instance.provide({ directory: tmp.path, - fn: async () => { - const filepath = path.join(tmp.path, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-2") + fn: () => + run( + Instruction.Service.use((svc) => + Effect.gen(function* () { + const filepath = path.join(tmp.path, "subdir", "nested", "file.ts") + const id = MessageID.make("message-claim-2") - const first = await Instruction.resolve([], filepath, id) - await Instruction.clear(id) - const second = await Instruction.resolve([], filepath, id) + const first = yield* svc.resolve([], filepath, id) + yield* svc.clear(id) + const second = yield* svc.resolve([], filepath, id) - expect(first).toHaveLength(1) - expect(second).toHaveLength(1) - expect(second[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) - }, + expect(first).toHaveLength(1) + expect(second).toHaveLength(1) + expect(second[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) + }), + ), + ), }) }) @@ -171,15 +200,19 @@ describe("Instruction.resolve", () => { }) await Instance.provide({ directory: tmp.path, - fn: async () => { - const agents = path.join(tmp.path, "subdir", "AGENTS.md") - const filepath = path.join(tmp.path, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-3") + fn: () => + run( + Instruction.Service.use((svc) => + Effect.gen(function* () { + const agents = path.join(tmp.path, "subdir", "AGENTS.md") + const filepath = path.join(tmp.path, "subdir", "nested", "file.ts") + const id = MessageID.make("message-claim-3") - const results = await Instruction.resolve(loaded(agents), filepath, id) - - expect(results).toEqual([]) - }, + const results = yield* svc.resolve(loaded(agents), filepath, id) + expect(results).toEqual([]) + }), + ), + ), }) }) @@ -221,11 +254,16 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => { try { await Instance.provide({ directory: projectTmp.path, - fn: async () => { - const paths = await Instruction.systemPaths() - expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true) - expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false) - }, + fn: () => + run( + Instruction.Service.use((svc) => + Effect.gen(function* () { + const paths = yield* svc.systemPaths() + expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true) + expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false) + }), + ), + ), }) } finally { ;(Global.Path as { config: string }).config = originalGlobalConfig @@ -248,11 +286,16 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => { try { await Instance.provide({ directory: projectTmp.path, - fn: async () => { - const paths = await Instruction.systemPaths() - expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false) - expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) - }, + fn: () => + run( + Instruction.Service.use((svc) => + Effect.gen(function* () { + const paths = yield* svc.systemPaths() + expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false) + expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) + }), + ), + ), }) } finally { ;(Global.Path as { config: string }).config = originalGlobalConfig @@ -274,10 +317,15 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => { try { await Instance.provide({ directory: projectTmp.path, - fn: async () => { - const paths = await Instruction.systemPaths() - expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) - }, + fn: () => + run( + Instruction.Service.use((svc) => + Effect.gen(function* () { + const paths = yield* svc.systemPaths() + expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) + }), + ), + ), }) } finally { ;(Global.Path as { config: string }).config = originalGlobalConfig diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 3634d6fb7..64a5d3e4b 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -570,6 +570,81 @@ describe("session.message-v2.toModelMessage", () => { ]) }) + test("forwards partial bash output for aborted tool calls", async () => { + const userID = "m-user" + const assistantID = "m-assistant" + const output = [ + "31403", + "12179", + "4575", + "", + "", + "User aborted the command", + "", + ].join("\n") + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "run tool", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "tool", + callID: "call-1", + tool: "bash", + state: { + status: "error", + input: { command: "for i in {1..20}; do print -- $RANDOM; sleep 1; done" }, + error: "Tool execution aborted", + metadata: { interrupted: true, output }, + time: { start: 0, end: 1 }, + }, + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "run tool" }], + }, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "bash", + input: { command: "for i in {1..20}; do print -- $RANDOM; sleep 1; done" }, + providerExecuted: undefined, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "bash", + output: { type: "text", value: output }, + }, + ], + }, + ]) + }) + test("filters assistant messages with non-abort errors", async () => { const assistantID = "m-assistant" diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 0fc25c1a6..a3b335b6d 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -21,7 +21,7 @@ import { Log } from "../../src/util/log" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" -import { reply, TestLLMServer } from "../lib/llm-server" +import { raw, reply, TestLLMServer } from "../lib/llm-server" Log.init({ print: false }) @@ -218,6 +218,93 @@ it.live("session.processor effect tests capture llm input cleanly", () => ), ) +it.live("session.processor effect tests preserve text start time", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const gate = defer() + const { processors, session, provider } = yield* boot() + + yield* llm.push( + raw({ + head: [ + { + id: "chatcmpl-test", + object: "chat.completion.chunk", + choices: [{ delta: { role: "assistant" } }], + }, + { + id: "chatcmpl-test", + object: "chat.completion.chunk", + choices: [{ delta: { content: "hello" } }], + }, + ], + wait: gate.promise, + tail: [ + { + id: "chatcmpl-test", + object: "chat.completion.chunk", + choices: [{ delta: {}, finish_reason: "stop" }], + }, + ], + }), + ) + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "hi") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + + const run = yield* handle + .process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies MessageV2.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "hi" }], + tools: {}, + }) + .pipe(Effect.forkChild) + + yield* Effect.promise(async () => { + const stop = Date.now() + 500 + while (Date.now() < stop) { + const text = MessageV2.parts(msg.id).find((part): part is MessageV2.TextPart => part.type === "text") + if (text?.time?.start) return + await Bun.sleep(10) + } + throw new Error("timed out waiting for text part") + }) + yield* Effect.sleep("20 millis") + gate.resolve() + + const exit = yield* Fiber.await(run) + const text = MessageV2.parts(msg.id).find((part): part is MessageV2.TextPart => part.type === "text") + + expect(Exit.isSuccess(exit)).toBe(true) + expect(text?.text).toBe("hello") + expect(text?.time?.start).toBeDefined() + expect(text?.time?.end).toBeDefined() + if (!text?.time?.start || !text.time.end) return + expect(text.time.start).toBeLessThan(text.time.end) + }), + { git: true, config: (url) => providerCfg(url) }, + ), +) + it.live("session.processor effect tests stop after token overflow requests compaction", () => provideTmpdirServer( ({ dir, llm }) => @@ -593,9 +680,6 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup yield* Fiber.interrupt(run) const exit = yield* Fiber.await(run) - if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) { - yield* handle.abort() - } const parts = MessageV2.parts(msg.id) const call = parts.find((part): part is MessageV2.ToolPart => part.type === "tool") @@ -607,6 +691,7 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup expect(call?.state.status).toBe("error") if (call?.state.status === "error") { expect(call.state.error).toBe("Tool execution aborted") + expect(call.state.metadata?.interrupted).toBe(true) expect(call.state.time.end).toBeDefined() } }), @@ -665,9 +750,6 @@ it.live("session.processor effect tests record aborted errors and idle state", ( yield* Fiber.interrupt(run) const exit = yield* Fiber.await(run) - if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) { - yield* handle.abort() - } yield* Effect.promise(() => seen.promise) const stored = MessageV2.get({ sessionID: chat.id, messageID: msg.id }) const state = yield* sts.get(chat.id) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 17689cf27..911c9f344 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -1,5 +1,6 @@ import { NodeFileSystem } from "@effect/platform-node" -import { expect, spyOn } from "bun:test" +import { FetchHttpClient } from "effect/unstable/http" +import { expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" import path from "path" import z from "zod" @@ -25,15 +26,20 @@ import { SessionCompaction } from "../../src/session/compaction" import { Instruction } from "../../src/session/instruction" import { SessionProcessor } from "../../src/session/processor" import { SessionPrompt } from "../../src/session/prompt" +import { SessionRevert } from "../../src/session/revert" +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 { TaskTool } from "../../src/tool/task" import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" import { Log } from "../../src/util/log" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { Ripgrep } from "../../src/file/ripgrep" +import { Format } from "../../src/format" import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { reply, TestLLMServer } from "../lib/llm-server" @@ -139,11 +145,12 @@ const filetime = Layer.succeed( read: () => Effect.void, get: () => Effect.succeed(undefined), assert: () => Effect.void, - withLock: (_filepath, fn) => Effect.promise(fn), + withLock: (_filepath, fn) => fn(), }), ) const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) +const run = SessionRunState.layer.pipe(Layer.provide(status)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) function makeHttp() { const deps = Layer.mergeAll( @@ -165,6 +172,11 @@ function makeHttp() { const question = Question.layer.pipe(Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps)) const registry = ToolRegistry.layer.pipe( + Layer.provide(Skill.defaultLayer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Format.defaultLayer), Layer.provideMerge(todo), Layer.provideMerge(question), Layer.provideMerge(deps), @@ -175,11 +187,14 @@ function makeHttp() { return Layer.mergeAll( TestLLMServer.layer, SessionPrompt.layer.pipe( + Layer.provide(SessionRevert.defaultLayer), + Layer.provideMerge(run), Layer.provideMerge(compact), Layer.provideMerge(proc), Layer.provideMerge(registry), Layer.provideMerge(trunc), Layer.provide(Instruction.defaultLayer), + Layer.provide(SystemPrompt.defaultLayer), Layer.provideMerge(deps), ), ) @@ -301,9 +316,10 @@ const addSubtask = (sessionID: SessionID, messageID: MessageID, model = ref) => const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) { const prompt = yield* SessionPrompt.Service + const run = yield* SessionRunState.Service const sessions = yield* Session.Service const chat = yield* sessions.create(input ?? { title: "Pinned" }) - return { prompt, sessions, chat } + return { prompt, run, sessions, chat } }) // Loop semantics @@ -539,6 +555,93 @@ it.live("failed subtask preserves metadata on error tool state", () => ), ) +it.live( + "running subtask preserves metadata after tool-call transition", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.hang + const msg = yield* user(chat.id, "hello") + yield* addSubtask(chat.id, msg.id) + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + + const tool = yield* Effect.promise(async () => { + const end = Date.now() + 5_000 + while (Date.now() < end) { + const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id)) + const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") + const tool = taskMsg?.parts.find((part): part is MessageV2.ToolPart => part.type === "tool") + if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool + await new Promise((done) => setTimeout(done, 20)) + } + throw new Error("timed out waiting for running subtask metadata") + }) + + if (tool.state.status !== "running") return + expect(typeof tool.state.metadata?.sessionId).toBe("string") + expect(tool.state.title).toBeDefined() + expect(tool.state.metadata?.model).toBeDefined() + + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + }), + { git: true, config: providerCfg }, + ), + 5_000, +) + +it.live( + "running task tool preserves metadata after tool-call transition", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* llm.tool("task", { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }) + yield* llm.hang + yield* user(chat.id, "hello") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + + const tool = yield* Effect.promise(async () => { + const end = Date.now() + 5_000 + while (Date.now() < end) { + const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id)) + const assistant = msgs.findLast((item) => item.info.role === "assistant" && item.info.agent === "build") + const tool = assistant?.parts.find( + (part): part is MessageV2.ToolPart => part.type === "tool" && part.tool === "task", + ) + if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool + await new Promise((done) => setTimeout(done, 20)) + } + throw new Error("timed out waiting for running task metadata") + }) + + if (tool.state.status !== "running") return + expect(typeof tool.state.metadata?.sessionId).toBe("string") + expect(tool.state.title).toBe("inspect bug") + expect(tool.state.metadata?.model).toBeDefined() + + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + }), + { git: true, config: providerCfg }, + ), + 10_000, +) + it.live( "loop sets status to busy then idle", () => @@ -627,34 +730,20 @@ it.live( "cancel finalizes subtask tool state", () => provideTmpdirInstance( - (dir) => + () => Effect.gen(function* () { const ready = defer() const aborted = defer() - const init = spyOn(TaskTool, "init").mockImplementation(async () => ({ - description: "task", - parameters: z.object({ - description: z.string(), - prompt: z.string(), - subagent_type: z.string(), - task_id: z.string().optional(), - command: z.string().optional(), - }), - execute: async (_args, ctx) => { + const registry = yield* ToolRegistry.Service + const { task } = yield* registry.named() + const original = task.execute + task.execute = (_args, ctx) => + Effect.callback((resume) => { ready.resolve() ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true }) - await new Promise(() => {}) - return { - title: "", - metadata: { - sessionId: SessionID.make("task"), - model: ref, - }, - output: "", - } - }, - })) - yield* Effect.addFinalizer(() => Effect.sync(() => init.mockRestore())) + return Effect.sync(() => aborted.resolve()) + }) + yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original))) const { prompt, chat } = yield* boot() const msg = yield* user(chat.id, "hello") @@ -721,7 +810,7 @@ it.live("concurrent loop callers get same result", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const { prompt, chat } = yield* boot() + const { prompt, run, chat } = yield* boot() yield* seed(chat.id, { finish: "stop" }) const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], { @@ -730,7 +819,7 @@ it.live("concurrent loop callers get same result", () => expect(a.info.id).toBe(b.info.id) expect(a.info.role).toBe("assistant") - yield* prompt.assertNotBusy(chat.id) + yield* run.assertNotBusy(chat.id) }), { git: true }, ), @@ -834,6 +923,7 @@ it.live( provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) { const prompt = yield* SessionPrompt.Service + const run = yield* SessionRunState.Service const sessions = yield* Session.Service yield* llm.hang @@ -843,7 +933,7 @@ it.live( const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) yield* llm.wait(1) - const exit = yield* prompt.assertNotBusy(chat.id).pipe(Effect.exit) + const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) @@ -861,11 +951,11 @@ it.live("assertNotBusy succeeds when idle", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service + const run = yield* SessionRunState.Service const sessions = yield* Session.Service const chat = yield* sessions.create({}) - const exit = yield* prompt.assertNotBusy(chat.id).pipe(Effect.exit) + const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) expect(Exit.isSuccess(exit)).toBe(true) }), { git: true }, @@ -906,7 +996,7 @@ unix("shell captures stdout and stderr in completed tool output", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const { prompt, chat } = yield* boot() + const { prompt, run, chat } = yield* boot() const result = yield* prompt.shell({ sessionID: chat.id, agent: "build", @@ -921,7 +1011,7 @@ unix("shell captures stdout and stderr in completed tool output", () => expect(tool.state.output).toContain("err") expect(tool.state.metadata.output).toContain("out") expect(tool.state.metadata.output).toContain("err") - yield* prompt.assertNotBusy(chat.id) + yield* run.assertNotBusy(chat.id) }), { git: true, config: cfg }, ), @@ -931,7 +1021,7 @@ unix("shell completes a fast command on the preferred shell", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const { prompt, chat } = yield* boot() + const { prompt, run, chat } = yield* boot() const result = yield* prompt.shell({ sessionID: chat.id, agent: "build", @@ -945,7 +1035,7 @@ unix("shell completes a fast command on the preferred shell", () => expect(tool.state.input.command).toBe("pwd") expect(tool.state.output).toContain(dir) expect(tool.state.metadata.output).toContain(dir) - yield* prompt.assertNotBusy(chat.id) + yield* run.assertNotBusy(chat.id) }), { git: true, config: cfg }, ), @@ -955,7 +1045,7 @@ unix("shell lists files from the project directory", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const { prompt, chat } = yield* boot() + const { prompt, run, chat } = yield* boot() yield* Effect.promise(() => Bun.write(path.join(dir, "README.md"), "# e2e\n")) const result = yield* prompt.shell({ @@ -971,7 +1061,7 @@ unix("shell lists files from the project directory", () => expect(tool.state.input.command).toBe("command ls") expect(tool.state.output).toContain("README.md") expect(tool.state.metadata.output).toContain("README.md") - yield* prompt.assertNotBusy(chat.id) + yield* run.assertNotBusy(chat.id) }), { git: true, config: cfg }, ), @@ -981,7 +1071,7 @@ unix("shell captures stderr from a failing command", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const { prompt, chat } = yield* boot() + const { prompt, run, chat } = yield* boot() const result = yield* prompt.shell({ sessionID: chat.id, agent: "build", @@ -994,7 +1084,7 @@ unix("shell captures stderr from a failing command", () => expect(tool.state.output).toContain("not found") expect(tool.state.metadata.output).toContain("not found") - yield* prompt.assertNotBusy(chat.id) + yield* run.assertNotBusy(chat.id) }), { git: true, config: cfg }, ), @@ -1119,7 +1209,7 @@ unix( provideTmpdirInstance( (dir) => Effect.gen(function* () { - const { prompt, chat } = yield* boot() + const { prompt, run, chat } = yield* boot() const sh = yield* prompt .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }) @@ -1130,7 +1220,7 @@ unix( const status = yield* SessionStatus.Service expect((yield* status.get(chat.id)).type).toBe("idle") - const busy = yield* prompt.assertNotBusy(chat.id).pipe(Effect.exit) + const busy = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) expect(Exit.isSuccess(busy)).toBe(true) const exit = yield* Fiber.await(sh) @@ -1181,6 +1271,57 @@ unix( 30_000, ) +unix( + "cancel finalizes interrupted bash tool output through normal truncation", + () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Interrupted bash truncation", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "run bash" }], + }) + + yield* llm.tool("bash", { + command: + 'i=0; while [ "$i" -lt 4000 ]; do printf "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx %05d\\n" "$i"; i=$((i + 1)); done; sleep 30', + description: "Print many lines", + timeout: 30_000, + workdir: path.resolve(dir), + }) + + const run = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + yield* Effect.sleep(150) + yield* prompt.cancel(chat.id) + + const exit = yield* Fiber.await(run) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isFailure(exit)) return + + const tool = completedTool(exit.value.parts) + if (!tool) return + + expect(tool.state.metadata.truncated).toBe(true) + expect(typeof tool.state.metadata.outputPath).toBe("string") + expect(tool.state.output).toContain("The tool call succeeded but the output was truncated.") + expect(tool.state.output).toContain("Full output saved to:") + expect(tool.state.output).not.toContain("Tool execution aborted") + }), + { git: true, config: providerCfg }, + ), + 30_000, +) + unix( "cancel interrupts loop queued behind shell", () => @@ -1239,3 +1380,108 @@ unix( ), 30_000, ) + +// Abort signal propagation tests for inline tool execution + +/** Override a tool's execute to hang until aborted. Returns ready/aborted defers and a finalizer. */ +function hangUntilAborted(tool: { execute: (...args: any[]) => any }) { + const ready = defer() + const aborted = defer() + const original = tool.execute + tool.execute = (_args: any, ctx: any) => { + ready.resolve() + ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true }) + return Effect.callback(() => {}) + } + const restore = Effect.addFinalizer(() => Effect.sync(() => void (tool.execute = original))) + return { ready, aborted, restore } +} + +it.live( + "interrupt propagates abort signal to read tool via file part (text/plain)", + () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const { read } = yield* registry.named() + const { ready, aborted, restore } = hangUntilAborted(read) + yield* restore + + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Abort Test" }) + + const testFile = path.join(dir, "test.txt") + yield* Effect.promise(() => Bun.write(testFile, "hello world")) + + const fiber = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + parts: [ + { type: "text", text: "read this" }, + { type: "file", url: `file://${testFile}`, filename: "test.txt", mime: "text/plain" }, + ], + }) + .pipe(Effect.forkChild) + + yield* Effect.promise(() => ready.promise) + yield* Fiber.interrupt(fiber) + + yield* Effect.promise(() => + Promise.race([ + aborted.promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000), + ), + ]), + ) + }), + { git: true, config: cfg }, + ), + 30_000, +) + +it.live( + "interrupt propagates abort signal to read tool via file part (directory)", + () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const { read } = yield* registry.named() + const { ready, aborted, restore } = hangUntilAborted(read) + yield* restore + + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Abort Test" }) + + const fiber = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + parts: [ + { type: "text", text: "read this" }, + { type: "file", url: `file://${dir}`, filename: "dir", mime: "application/x-directory" }, + ], + }) + .pipe(Effect.forkChild) + + yield* Effect.promise(() => ready.promise) + yield* Fiber.interrupt(fiber) + + yield* Effect.promise(() => + Promise.race([ + aborted.promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000), + ), + ]), + ) + }), + { git: true, config: cfg }, + ), + 30_000, +) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 0204c7a11..a598c37fc 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -6,6 +6,7 @@ import { Effect, Schedule } from "effect" import { SessionRetry } from "../../src/session/retry" import { MessageV2 } from "../../src/session/message-v2" import { ProviderID } from "../../src/provider/schema" +import { AppRuntime } from "../../src/effect/app-runtime" import { SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { Instance } from "../../src/project/instance" @@ -94,12 +95,16 @@ describe("session.retry.delay", () => { parse: (err) => err as MessageV2.APIError, set: (info) => Effect.promise(() => - SessionStatus.set(sessionID, { - type: "retry", - attempt: info.attempt, - message: info.message, - next: info.next, - }), + AppRuntime.runPromise( + SessionStatus.Service.use((svc) => + svc.set(sessionID, { + type: "retry", + attempt: info.attempt, + message: info.message, + next: info.next, + }), + ), + ), ), }), ) @@ -108,7 +113,7 @@ describe("session.retry.delay", () => { }), ) - expect(await SessionStatus.get(sessionID)).toMatchObject({ + expect(await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.get(sessionID)))).toMatchObject({ type: "retry", attempt: 2, message: "boom", diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 0c18f92ba..75c74002a 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -6,6 +6,7 @@ import { Log } from "../../src/util/log" import { Instance } from "../../src/project/instance" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID } from "../../src/session/schema" +import { tmpdir } from "../fixture/fixture" const projectRoot = path.join(__dirname, "../..") Log.init({ print: false }) @@ -140,3 +141,25 @@ describe("step-finish token propagation via Bus event", () => { { timeout: 30000 }, ) }) + +describe("Session", () => { + test("remove works without an instance", async () => { + await using tmp = await tmpdir({ git: true }) + + const session = await Instance.provide({ + directory: tmp.path, + fn: async () => Session.create({ title: "remove-without-instance" }), + }) + + await expect(async () => { + await Session.remove(session.id) + }).not.toThrow() + + let missing = false + await Session.get(session.id).catch(() => { + missing = true + }) + + expect(missing).toBe(true) + }) +}) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index c192a446b..391d9d488 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -12,12 +12,14 @@ * tools internally during multi-step processing before emitting events. */ import { expect } from "bun:test" -import { Effect } from "effect" +import { Effect, Layer } from "effect" +import { FetchHttpClient } from "effect/unstable/http" import fs from "fs/promises" import path from "path" import { Session } from "../../src/session" import { LLM } from "../../src/session/llm" import { SessionPrompt } from "../../src/session/prompt" +import { SessionRevert } from "../../src/session/revert" import { SessionSummary } from "../../src/session/summary" import { MessageV2 } from "../../src/session/message-v2" import { Log } from "../../src/util/log" @@ -27,7 +29,6 @@ import { TestLLMServer } from "../lib/llm-server" // Same layer setup as prompt-effect.test.ts import { NodeFileSystem } from "@effect/platform-node" -import { Layer } from "effect" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Command } from "../../src/command" @@ -39,10 +40,13 @@ import { Permission } from "../../src/permission" 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" import { SessionProcessor } from "../../src/session/processor" +import { SessionRunState } from "../../src/session/run-state" import { SessionStatus } from "../../src/session/status" import { Shell } from "../../src/shell/shell" import { Snapshot } from "../../src/snapshot" @@ -50,6 +54,8 @@ import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" import { AppFileSystem } from "../../src/filesystem" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { Ripgrep } from "../../src/file/ripgrep" +import { Format } from "../../src/format" Log.init({ print: false }) @@ -102,11 +108,12 @@ const filetime = Layer.succeed( read: () => Effect.void, get: () => Effect.succeed(undefined), assert: () => Effect.void, - withLock: (_filepath, fn) => Effect.promise(fn), + withLock: (_filepath, fn) => fn(), }), ) const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) +const run = SessionRunState.layer.pipe(Layer.provide(status)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) function makeHttp() { @@ -129,6 +136,11 @@ function makeHttp() { const question = Question.layer.pipe(Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps)) const registry = ToolRegistry.layer.pipe( + Layer.provide(Skill.defaultLayer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Format.defaultLayer), Layer.provideMerge(todo), Layer.provideMerge(question), Layer.provideMerge(deps), @@ -139,11 +151,14 @@ function makeHttp() { return Layer.mergeAll( TestLLMServer.layer, SessionPrompt.layer.pipe( + Layer.provide(SessionRevert.defaultLayer), + Layer.provideMerge(run), Layer.provideMerge(compact), Layer.provideMerge(proc), Layer.provideMerge(registry), Layer.provideMerge(trunc), Layer.provide(Instruction.defaultLayer), + Layer.provide(SystemPrompt.defaultLayer), Layer.provideMerge(deps), ), ) diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 47f5f6fc2..6f1047a97 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -1,5 +1,6 @@ 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" @@ -38,8 +39,13 @@ description: ${description} directory: tmp.path, fn: async () => { const build = await Agent.get("build") - const first = await SystemPrompt.skills(build!) - const second = await SystemPrompt.skills(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) expect(first).toBe(second) diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index 6619b3c60..fd230f545 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -13,6 +13,7 @@ import { Provider } from "../../src/provider/provider" import { Session } from "../../src/session" import type { SessionID } from "../../src/session/schema" import { ShareNext } from "../../src/share/share-next" +import { Storage } from "../../src/storage/storage" import { SessionShareTable } from "../../src/share/share.sql" import { Database, eq } from "../../src/storage/db" import { provideTmpdirInstance } from "../fixture/fixture" @@ -55,7 +56,7 @@ function wired(client: HttpClient.HttpClient) { return Layer.mergeAll( Bus.layer, ShareNext.layer, - Session.layer, + Session.defaultLayer, AccountRepo.layer, NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer, diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 3cedfb941..971d053bd 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -511,6 +511,49 @@ test("circular symlinks", async () => { }) }) +test("source project gitignore is respected - ignored files are not snapshotted", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + // Create gitignore BEFORE any tracking + await Filesystem.write(`${dir}/.gitignore`, "*.ignored\nbuild/\nnode_modules/\n") + await Filesystem.write(`${dir}/tracked.txt`, "tracked content") + await Filesystem.write(`${dir}/ignored.ignored`, "ignored content") + await $`mkdir -p ${dir}/build`.quiet() + await Filesystem.write(`${dir}/build/output.js`, "build output") + await Filesystem.write(`${dir}/normal.js`, "normal js") + await $`git add .`.cwd(dir).quiet() + await $`git commit -m init`.cwd(dir).quiet() + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + // Modify tracked files and create new ones - some ignored, some not + await Filesystem.write(`${tmp.path}/tracked.txt`, "modified tracked") + await Filesystem.write(`${tmp.path}/new.ignored`, "new ignored") + await Filesystem.write(`${tmp.path}/new-tracked.txt`, "new tracked") + await Filesystem.write(`${tmp.path}/build/new-build.js`, "new build file") + + const patch = await Snapshot.patch(before!) + + // Modified and new tracked files should be in snapshot + expect(patch.files).toContain(fwd(tmp.path, "new-tracked.txt")) + expect(patch.files).toContain(fwd(tmp.path, "tracked.txt")) + + // Ignored files should NOT be in snapshot + expect(patch.files).not.toContain(fwd(tmp.path, "new.ignored")) + expect(patch.files).not.toContain(fwd(tmp.path, "ignored.ignored")) + expect(patch.files).not.toContain(fwd(tmp.path, "build/output.js")) + expect(patch.files).not.toContain(fwd(tmp.path, "build/new-build.js")) + }, + }) +}) + test("gitignore changes", async () => { await using tmp = await bootstrap() await Instance.provide({ @@ -535,6 +578,75 @@ test("gitignore changes", async () => { }) }) +test("files tracked in snapshot but now gitignored are filtered out", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // First, create a file and snapshot it + await Filesystem.write(`${tmp.path}/later-ignored.txt`, "initial content") + const before = await Snapshot.track() + expect(before).toBeTruthy() + + // Modify the file (so it appears in diff-files) + await Filesystem.write(`${tmp.path}/later-ignored.txt`, "modified content") + + // Now add gitignore that would exclude this file + await Filesystem.write(`${tmp.path}/.gitignore`, "later-ignored.txt\n") + + // Also create another tracked file + await Filesystem.write(`${tmp.path}/still-tracked.txt`, "new tracked file") + + const patch = await Snapshot.patch(before!) + + // The file that is now gitignored should NOT appear, even though it was + // previously tracked and modified + expect(patch.files).not.toContain(fwd(tmp.path, "later-ignored.txt")) + + // The gitignore file itself should appear + expect(patch.files).toContain(fwd(tmp.path, ".gitignore")) + + // Other tracked files should appear + expect(patch.files).toContain(fwd(tmp.path, "still-tracked.txt")) + }, + }) +}) + +test("gitignore updated between track calls filters from diff", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // a.txt is already committed from bootstrap - track it in snapshot + const before = await Snapshot.track() + expect(before).toBeTruthy() + + // Modify a.txt (so it appears in diff-files) + await Filesystem.write(`${tmp.path}/a.txt`, "modified content") + + // Now add gitignore that would exclude a.txt + await Filesystem.write(`${tmp.path}/.gitignore`, "a.txt\n") + + // Also modify b.txt which is not gitignored + await Filesystem.write(`${tmp.path}/b.txt`, "also modified") + + // Second track - should not include a.txt even though it changed + const after = await Snapshot.track() + expect(after).toBeTruthy() + + // Verify a.txt is NOT in the diff between snapshots + const diffs = await Snapshot.diffFull(before!, after!) + expect(diffs.some((x) => x.file === "a.txt")).toBe(false) + + // But .gitignore should be in the diff + expect(diffs.some((x) => x.file === ".gitignore")).toBe(true) + + // b.txt should be in the diff (not gitignored) + expect(diffs.some((x) => x.file === "b.txt")).toBe(true) + }, + }) +}) + test("git info exclude changes", async () => { await using tmp = await bootstrap() await Instance.provide({ diff --git a/packages/opencode/test/storage/json-migration.test.ts b/packages/opencode/test/storage/json-migration.test.ts index a714f1147..e76401ae7 100644 --- a/packages/opencode/test/storage/json-migration.test.ts +++ b/packages/opencode/test/storage/json-migration.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test" import { Database } from "bun:sqlite" -import { drizzle } from "drizzle-orm/bun-sqlite" +import { drizzle, SQLiteBunDatabase } from "drizzle-orm/bun-sqlite" import { migrate } from "drizzle-orm/bun-sqlite/migrator" import path from "path" import fs from "fs/promises" @@ -89,18 +89,21 @@ function createTestDb() { name: entry.name, })) .sort((a, b) => a.timestamp - b.timestamp) - migrate(drizzle({ client: sqlite }), migrations) - return sqlite + const db = drizzle({ client: sqlite }) + migrate(db, migrations) + + return [sqlite, db] as const } describe("JSON to SQLite migration", () => { let storageDir: string let sqlite: Database + let db: SQLiteBunDatabase beforeEach(async () => { storageDir = await setupStorageDir() - sqlite = createTestDb() + ;[sqlite, db] = createTestDb() }) afterEach(async () => { @@ -118,11 +121,10 @@ describe("JSON to SQLite migration", () => { sandboxes: ["/test/sandbox"], }) - const stats = await JsonMigration.run(sqlite) + const stats = await JsonMigration.run(db) expect(stats?.projects).toBe(1) - const db = drizzle({ client: sqlite }) const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) expect(projects[0].id).toBe(ProjectID.make("proj_test123abc")) @@ -143,11 +145,10 @@ describe("JSON to SQLite migration", () => { }), ) - const stats = await JsonMigration.run(sqlite) + const stats = await JsonMigration.run(db) expect(stats?.projects).toBe(1) - const db = drizzle({ client: sqlite }) const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) expect(projects[0].id).toBe(ProjectID.make("proj_filename")) // Uses filename, not JSON id @@ -164,11 +165,10 @@ describe("JSON to SQLite migration", () => { commands: { start: "npm run dev" }, }) - const stats = await JsonMigration.run(sqlite) + const stats = await JsonMigration.run(db) expect(stats?.projects).toBe(1) - const db = drizzle({ client: sqlite }) const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) expect(projects[0].id).toBe(ProjectID.make("proj_with_commands")) @@ -185,11 +185,10 @@ describe("JSON to SQLite migration", () => { sandboxes: [], }) - const stats = await JsonMigration.run(sqlite) + const stats = await JsonMigration.run(db) expect(stats?.projects).toBe(1) - const db = drizzle({ client: sqlite }) const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) expect(projects[0].id).toBe(ProjectID.make("proj_no_commands")) @@ -216,9 +215,8 @@ describe("JSON to SQLite migration", () => { share: { url: "https://example.com/share" }, }) - await JsonMigration.run(sqlite) + await JsonMigration.run(db) - const db = drizzle({ client: sqlite }) const sessions = db.select().from(SessionTable).all() expect(sessions.length).toBe(1) expect(sessions[0].id).toBe(SessionID.make("ses_test456def")) @@ -247,12 +245,11 @@ describe("JSON to SQLite migration", () => { JSON.stringify({ ...fixtures.part }), ) - const stats = await JsonMigration.run(sqlite) + const stats = await JsonMigration.run(db) expect(stats?.messages).toBe(1) expect(stats?.parts).toBe(1) - const db = drizzle({ client: sqlite }) const messages = db.select().from(MessageTable).all() expect(messages.length).toBe(1) expect(messages[0].id).toBe(MessageID.make("msg_test789ghi")) @@ -287,12 +284,11 @@ describe("JSON to SQLite migration", () => { }), ) - const stats = await JsonMigration.run(sqlite) + const stats = await JsonMigration.run(db) expect(stats?.messages).toBe(1) expect(stats?.parts).toBe(1) - const db = drizzle({ client: sqlite }) const messages = db.select().from(MessageTable).all() expect(messages.length).toBe(1) expect(messages[0].id).toBe(MessageID.make("msg_test789ghi")) @@ -329,11 +325,10 @@ describe("JSON to SQLite migration", () => { }), ) - const stats = await JsonMigration.run(sqlite) + const stats = await JsonMigration.run(db) expect(stats?.messages).toBe(1) - const db = drizzle({ client: sqlite }) const messages = db.select().from(MessageTable).all() expect(messages.length).toBe(1) expect(messages[0].id).toBe(MessageID.make("msg_from_filename")) // Uses filename, not JSON id @@ -367,11 +362,10 @@ describe("JSON to SQLite migration", () => { }), ) - const stats = await JsonMigration.run(sqlite) + const stats = await JsonMigration.run(db) expect(stats?.parts).toBe(1) - const db = drizzle({ client: sqlite }) const parts = db.select().from(PartTable).all() expect(parts.length).toBe(1) expect(parts[0].id).toBe(PartID.make("prt_from_filename")) // Uses filename, not JSON id @@ -392,7 +386,7 @@ describe("JSON to SQLite migration", () => { }), ) - const stats = await JsonMigration.run(sqlite) + const stats = await JsonMigration.run(db) expect(stats?.sessions).toBe(0) }) @@ -420,11 +414,10 @@ describe("JSON to SQLite migration", () => { time: { created: 1700000000000, updated: 1700000001000 }, }) - const stats = await JsonMigration.run(sqlite) + const stats = await JsonMigration.run(db) expect(stats?.sessions).toBe(1) - const db = drizzle({ client: sqlite }) const sessions = db.select().from(SessionTable).all() expect(sessions.length).toBe(1) expect(sessions[0].id).toBe(SessionID.make("ses_migrated")) @@ -452,11 +445,10 @@ describe("JSON to SQLite migration", () => { }), ) - const stats = await JsonMigration.run(sqlite) + const stats = await JsonMigration.run(db) expect(stats?.sessions).toBe(1) - const db = drizzle({ client: sqlite }) const sessions = db.select().from(SessionTable).all() expect(sessions.length).toBe(1) expect(sessions[0].id).toBe(SessionID.make("ses_from_filename")) // Uses filename, not JSON id @@ -471,10 +463,9 @@ describe("JSON to SQLite migration", () => { sandboxes: [], }) - await JsonMigration.run(sqlite) - await JsonMigration.run(sqlite) + await JsonMigration.run(db) + await JsonMigration.run(db) - const db = drizzle({ client: sqlite }) const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) // Still only 1 due to onConflictDoNothing }) @@ -507,11 +498,10 @@ describe("JSON to SQLite migration", () => { ]), ) - const stats = await JsonMigration.run(sqlite) + const stats = await JsonMigration.run(db) expect(stats?.todos).toBe(2) - const db = drizzle({ client: sqlite }) const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all() expect(todos.length).toBe(2) expect(todos[0].content).toBe("First todo") @@ -540,9 +530,8 @@ describe("JSON to SQLite migration", () => { ]), ) - await JsonMigration.run(sqlite) + await JsonMigration.run(db) - const db = drizzle({ client: sqlite }) const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all() expect(todos.length).toBe(3) @@ -570,11 +559,10 @@ describe("JSON to SQLite migration", () => { ] await Bun.write(path.join(storageDir, "permission", "proj_test123abc.json"), JSON.stringify(permissionData)) - const stats = await JsonMigration.run(sqlite) + const stats = await JsonMigration.run(db) expect(stats?.permissions).toBe(1) - const db = drizzle({ client: sqlite }) const permissions = db.select().from(PermissionTable).all() expect(permissions.length).toBe(1) expect(permissions[0].project_id).toBe("proj_test123abc") @@ -600,11 +588,10 @@ describe("JSON to SQLite migration", () => { }), ) - const stats = await JsonMigration.run(sqlite) + const stats = await JsonMigration.run(db) expect(stats?.shares).toBe(1) - const db = drizzle({ client: sqlite }) const shares = db.select().from(SessionShareTable).all() expect(shares.length).toBe(1) expect(shares[0].session_id).toBe("ses_test456def") @@ -616,7 +603,7 @@ describe("JSON to SQLite migration", () => { test("returns empty stats when storage directory does not exist", async () => { await fs.rm(storageDir, { recursive: true, force: true }) - const stats = await JsonMigration.run(sqlite) + const stats = await JsonMigration.run(db) expect(stats.projects).toBe(0) expect(stats.sessions).toBe(0) @@ -637,12 +624,11 @@ describe("JSON to SQLite migration", () => { }) await Bun.write(path.join(storageDir, "project", "broken.json"), "{ invalid json") - const stats = await JsonMigration.run(sqlite) + const stats = await JsonMigration.run(db) expect(stats.projects).toBe(1) expect(stats.errors.some((x) => x.includes("failed to read") && x.includes("broken.json"))).toBe(true) - const db = drizzle({ client: sqlite }) const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) expect(projects[0].id).toBe(ProjectID.make("proj_test123abc")) @@ -666,10 +652,9 @@ describe("JSON to SQLite migration", () => { ]), ) - const stats = await JsonMigration.run(sqlite) + const stats = await JsonMigration.run(db) expect(stats.todos).toBe(2) - const db = drizzle({ client: sqlite }) const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all() expect(todos.length).toBe(2) expect(todos[0].content).toBe("keep-0") @@ -714,13 +699,12 @@ describe("JSON to SQLite migration", () => { JSON.stringify({ id: "share_missing", secret: "secret", url: "https://missing.example.com" }), ) - const stats = await JsonMigration.run(sqlite) + const stats = await JsonMigration.run(db) expect(stats.todos).toBe(1) expect(stats.permissions).toBe(1) expect(stats.shares).toBe(1) - const db = drizzle({ client: sqlite }) expect(db.select().from(TodoTable).all().length).toBe(1) expect(db.select().from(PermissionTable).all().length).toBe(1) expect(db.select().from(SessionShareTable).all().length).toBe(1) @@ -823,7 +807,7 @@ describe("JSON to SQLite migration", () => { ) await Bun.write(path.join(storageDir, "session_share", "ses_broken.json"), "{ nope") - const stats = await JsonMigration.run(sqlite) + const stats = await JsonMigration.run(db) // Projects: proj_test123abc (valid), proj_missing_id (now derives id from filename) // Sessions: ses_test456def (valid), ses_missing_project (now uses dir path), @@ -837,7 +821,6 @@ describe("JSON to SQLite migration", () => { expect(stats.shares).toBe(1) expect(stats.errors.length).toBeGreaterThanOrEqual(6) - const db = drizzle({ client: sqlite }) expect(db.select().from(ProjectTable).all().length).toBe(2) expect(db.select().from(SessionTable).all().length).toBe(3) expect(db.select().from(MessageTable).all().length).toBe(1) diff --git a/packages/opencode/test/storage/storage.test.ts b/packages/opencode/test/storage/storage.test.ts index e5a04c082..ea8f1feb4 100644 --- a/packages/opencode/test/storage/storage.test.ts +++ b/packages/opencode/test/storage/storage.test.ts @@ -1,295 +1,293 @@ -import { describe, expect, test } from "bun:test" -import fs from "fs/promises" +import { describe, expect } from "bun:test" import path from "path" -import { Effect, Layer, ManagedRuntime } from "effect" +import { Effect, Exit, Layer } from "effect" import { AppFileSystem } from "../../src/filesystem" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { Git } from "../../src/git" import { Global } from "../../src/global" import { Storage } from "../../src/storage/storage" -import { tmpdir } from "../fixture/fixture" +import { tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" const dir = path.join(Global.Path.data, "storage") -async function withScope(fn: (root: string[]) => Promise) { - const root = ["storage_test", crypto.randomUUID()] - try { - return await fn(root) - } finally { - await fs.rm(path.join(dir, ...root), { recursive: true, force: true }) - } -} +const it = testEffect(Layer.mergeAll(Storage.defaultLayer, AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer)) -function map(root: string, file: string) { +const scope = Effect.fnUntraced(function* () { + const root = ["storage_test", crypto.randomUUID()] + const fs = yield* AppFileSystem.Service + const svc = yield* Storage.Service + yield* Effect.addFinalizer(() => + fs.remove(path.join(dir, ...root), { recursive: true, force: true }).pipe(Effect.ignore), + ) + return { root, svc } +}) + +// remap(root) rewrites any path under Global.Path.data to live under `root` instead. +// Used by remappedFs to build an AppFileSystem that Storage thinks is the real global +// data dir but actually targets a tmp dir — letting migration tests stage legacy layouts. +// NOTE: only the 6 methods below are intercepted. If Storage starts using a different +// AppFileSystem method that touches Global.Path.data, add it here. +function remap(root: string, file: string) { if (file === Global.Path.data) return root if (file.startsWith(Global.Path.data + path.sep)) return path.join(root, path.relative(Global.Path.data, file)) return file } -function layer(root: string) { +function remappedFs(root: string) { return Layer.effect( AppFileSystem.Service, Effect.gen(function* () { const fs = yield* AppFileSystem.Service return AppFileSystem.Service.of({ ...fs, - isDir: (file) => fs.isDir(map(root, file)), - readJson: (file) => fs.readJson(map(root, file)), - writeWithDirs: (file, content, mode) => fs.writeWithDirs(map(root, file), content, mode), - readFileString: (file) => fs.readFileString(map(root, file)), - remove: (file) => fs.remove(map(root, file)), + isDir: (file) => fs.isDir(remap(root, file)), + readJson: (file) => fs.readJson(remap(root, file)), + writeWithDirs: (file, content, mode) => fs.writeWithDirs(remap(root, file), content, mode), + readFileString: (file) => fs.readFileString(remap(root, file)), + remove: (file) => fs.remove(remap(root, file)), glob: (pattern, options) => - fs.glob(pattern, options?.cwd ? { ...options, cwd: map(root, options.cwd) } : options), + fs.glob(pattern, options?.cwd ? { ...options, cwd: remap(root, options.cwd) } : options), }) }), ).pipe(Layer.provide(AppFileSystem.defaultLayer)) } -async function withStorage( - root: string, - fn: (run: (body: Effect.Effect) => Promise) => Promise, -) { - const rt = ManagedRuntime.make(Storage.layer.pipe(Layer.provide(layer(root)))) - try { - return await fn((body) => rt.runPromise(body)) - } finally { - await rt.dispose() - } -} - -async function write(file: string, value: unknown) { - await fs.mkdir(path.dirname(file), { recursive: true }) - await Bun.write(file, JSON.stringify(value, null, 2)) -} - -async function text(file: string, value: string) { - await fs.mkdir(path.dirname(file), { recursive: true }) - await Bun.write(file, value) -} - -async function exists(file: string) { - return fs - .stat(file) - .then(() => true) - .catch(() => false) -} +// Layer.fresh forces a new Storage instance — without it, Effect's in-test layer cache +// returns the outer testEffect's Storage (which uses the real AppFileSystem), not a new +// one built on top of remappedFs. +const remappedStorage = (root: string) => + Layer.fresh(Storage.layer.pipe(Layer.provide(remappedFs(root)), Layer.provide(Git.defaultLayer))) describe("Storage", () => { - test("round-trips JSON content", async () => { - await withScope(async (root) => { + it.live("round-trips JSON content", () => + Effect.gen(function* () { + const { root, svc } = yield* scope() const key = [...root, "session_diff", "roundtrip"] const value = [{ file: "a.ts", additions: 2, deletions: 1 }] - await Storage.write(key, value) + yield* svc.write(key, value) + expect(yield* svc.read(key)).toEqual(value) + }), + ) - expect(await Storage.read(key)).toEqual(value) - }) - }) + it.live("maps missing reads to NotFoundError", () => + Effect.gen(function* () { + const { root, svc } = yield* scope() + const exit = yield* svc.read([...root, "missing", "value"]).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + }), + ) - test("maps missing reads to NotFoundError", async () => { - await withScope(async (root) => { - await expect(Storage.read([...root, "missing", "value"])).rejects.toMatchObject({ name: "NotFoundError" }) - }) - }) - - test("update on missing key throws NotFoundError", async () => { - await withScope(async (root) => { - await expect( - Storage.update<{ value: number }>([...root, "missing", "key"], (draft) => { + it.live("update on missing key throws NotFoundError", () => + Effect.gen(function* () { + const { root, svc } = yield* scope() + const exit = yield* svc + .update<{ value: number }>([...root, "missing", "key"], (draft) => { draft.value += 1 - }), - ).rejects.toMatchObject({ name: "NotFoundError" }) - }) - }) + }) + .pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + }), + ) - test("write overwrites existing value", async () => { - await withScope(async (root) => { + it.live("write overwrites existing value", () => + Effect.gen(function* () { + const { root, svc } = yield* scope() const key = [...root, "overwrite", "test"] - await Storage.write<{ v: number }>(key, { v: 1 }) - await Storage.write<{ v: number }>(key, { v: 2 }) - expect(await Storage.read<{ v: number }>(key)).toEqual({ v: 2 }) - }) - }) + yield* svc.write<{ v: number }>(key, { v: 1 }) + yield* svc.write<{ v: number }>(key, { v: 2 }) - test("remove on missing key is a no-op", async () => { - await withScope(async (root) => { - await expect(Storage.remove([...root, "nonexistent", "key"])).resolves.toBeUndefined() - }) - }) + expect(yield* svc.read<{ v: number }>(key)).toEqual({ v: 2 }) + }), + ) - test("list on missing prefix returns empty", async () => { - await withScope(async (root) => { - expect(await Storage.list([...root, "nonexistent"])).toEqual([]) - }) - }) + it.live("remove on missing key is a no-op", () => + Effect.gen(function* () { + const { root, svc } = yield* scope() + yield* svc.remove([...root, "nonexistent", "key"]) + }), + ) - test("serializes concurrent updates for the same key", async () => { - await withScope(async (root) => { + it.live("list on missing prefix returns empty", () => + Effect.gen(function* () { + const { root, svc } = yield* scope() + expect(yield* svc.list([...root, "nonexistent"])).toEqual([]) + }), + ) + + it.live("serializes concurrent updates for the same key", () => + Effect.gen(function* () { + const { root, svc } = yield* scope() const key = [...root, "counter", "shared"] - await Storage.write(key, { value: 0 }) - await Promise.all( + yield* svc.write(key, { value: 0 }) + + yield* Effect.all( Array.from({ length: 25 }, () => - Storage.update<{ value: number }>(key, (draft) => { + svc.update<{ value: number }>(key, (draft) => { draft.value += 1 }), ), + { concurrency: "unbounded" }, ) - expect(await Storage.read<{ value: number }>(key)).toEqual({ value: 25 }) - }) - }) + expect(yield* svc.read<{ value: number }>(key)).toEqual({ value: 25 }) + }), + ) - test("concurrent reads do not block each other", async () => { - await withScope(async (root) => { + it.live("concurrent reads do not block each other", () => + Effect.gen(function* () { + const { root, svc } = yield* scope() const key = [...root, "concurrent", "reads"] - await Storage.write(key, { ok: true }) - const results = await Promise.all(Array.from({ length: 10 }, () => Storage.read(key))) + yield* svc.write(key, { ok: true }) + + const results = yield* Effect.all( + Array.from({ length: 10 }, () => svc.read(key)), + { concurrency: "unbounded" }, + ) expect(results).toHaveLength(10) for (const r of results) expect(r).toEqual({ ok: true }) - }) - }) + }), + ) - test("nested keys create deep paths", async () => { - await withScope(async (root) => { + it.live("nested keys create deep paths", () => + Effect.gen(function* () { + const { root, svc } = yield* scope() const key = [...root, "a", "b", "c", "deep"] - await Storage.write<{ nested: boolean }>(key, { nested: true }) - expect(await Storage.read<{ nested: boolean }>(key)).toEqual({ nested: true }) - expect(await Storage.list([...root, "a"])).toEqual([key]) - }) - }) + yield* svc.write<{ nested: boolean }>(key, { nested: true }) - test("lists and removes stored entries", async () => { - await withScope(async (root) => { + expect(yield* svc.read<{ nested: boolean }>(key)).toEqual({ nested: true }) + expect(yield* svc.list([...root, "a"])).toEqual([key]) + }), + ) + + it.live("lists and removes stored entries", () => + Effect.gen(function* () { + const { root, svc } = yield* scope() const a = [...root, "list", "a"] const b = [...root, "list", "b"] const prefix = [...root, "list"] - await Storage.write(b, { value: 2 }) - await Storage.write(a, { value: 1 }) + yield* svc.write(b, { value: 2 }) + yield* svc.write(a, { value: 1 }) - expect(await Storage.list(prefix)).toEqual([a, b]) + expect(yield* svc.list(prefix)).toEqual([a, b]) - await Storage.remove(a) + yield* svc.remove(a) - expect(await Storage.list(prefix)).toEqual([b]) - await expect(Storage.read(a)).rejects.toMatchObject({ name: "NotFoundError" }) - }) - }) + expect(yield* svc.list(prefix)).toEqual([b]) + const exit = yield* svc.read(a).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + }), + ) - test("migration 2 runs when marker contents are invalid", async () => { - await using tmp = await tmpdir() - const storage = path.join(tmp.path, "storage") - const diffs = [ - { additions: 2, deletions: 1 }, - { additions: 3, deletions: 4 }, - ] + it.live("migration 2 runs when marker contents are invalid", () => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const tmp = yield* tmpdirScoped() + const storage = path.join(tmp, "storage") + const diffs = [ + { additions: 2, deletions: 1 }, + { additions: 3, deletions: 4 }, + ] - await text(path.join(storage, "migration"), "wat") - await write(path.join(storage, "session", "proj_test", "ses_test.json"), { - id: "ses_test", - projectID: "proj_test", - title: "legacy", - summary: { diffs }, - }) - - await withStorage(tmp.path, async (run) => { - expect(await run(Storage.Service.use((svc) => svc.list(["session_diff"])))).toEqual([ - ["session_diff", "ses_test"], - ]) - expect(await run(Storage.Service.use((svc) => svc.read(["session_diff", "ses_test"])))).toEqual( - diffs, + yield* fs.writeWithDirs(path.join(storage, "migration"), "wat") + yield* fs.writeWithDirs( + path.join(storage, "session", "proj_test", "ses_test.json"), + JSON.stringify({ + id: "ses_test", + projectID: "proj_test", + title: "legacy", + summary: { diffs }, + }), ) - expect( - await run( - Storage.Service.use((svc) => - svc.read<{ - id: string - projectID: string - title: string - summary: { - additions: number - deletions: number - } - }>(["session", "proj_test", "ses_test"]), - ), - ), - ).toEqual({ - id: "ses_test", - projectID: "proj_test", - title: "legacy", - summary: { - additions: 5, - deletions: 5, - }, - }) - }) - expect(await Bun.file(path.join(storage, "migration")).text()).toBe("2") - }) + yield* Effect.gen(function* () { + const svc = yield* Storage.Service + expect(yield* svc.list(["session_diff"])).toEqual([["session_diff", "ses_test"]]) + expect(yield* svc.read(["session_diff", "ses_test"])).toEqual(diffs) + expect( + yield* svc.read<{ + id: string + projectID: string + title: string + summary: { additions: number; deletions: number } + }>(["session", "proj_test", "ses_test"]), + ).toEqual({ + id: "ses_test", + projectID: "proj_test", + title: "legacy", + summary: { additions: 5, deletions: 5 }, + }) + }).pipe(Effect.provide(remappedStorage(tmp))) - test("migration 1 tolerates malformed legacy records", async () => { - await using tmp = await tmpdir({ git: true }) - const storage = path.join(tmp.path, "storage") - const legacy = path.join(tmp.path, "project", "legacy") + expect(yield* fs.readFileString(path.join(storage, "migration"))).toBe("2") + }), + ) - await write(path.join(legacy, "storage", "session", "message", "probe", "0.json"), []) - await write(path.join(legacy, "storage", "session", "message", "probe", "1.json"), { - path: { root: tmp.path }, - }) - await write(path.join(legacy, "storage", "session", "info", "ses_legacy.json"), { - id: "ses_legacy", - title: "legacy", - }) - await write(path.join(legacy, "storage", "session", "message", "ses_legacy", "msg_legacy.json"), { - role: "user", - text: "hello", - }) + it.live("migration 1 tolerates malformed legacy records", () => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const tmp = yield* tmpdirScoped({ git: true }) + const storage = path.join(tmp, "storage") + const legacy = path.join(tmp, "project", "legacy") - await withStorage(tmp.path, async (run) => { - const projects = await run(Storage.Service.use((svc) => svc.list(["project"]))) - expect(projects).toHaveLength(1) - const project = projects[0]![1] + yield* fs.writeWithDirs(path.join(legacy, "storage", "session", "message", "probe", "0.json"), "[]") + yield* fs.writeWithDirs( + path.join(legacy, "storage", "session", "message", "probe", "1.json"), + JSON.stringify({ path: { root: tmp } }), + ) + yield* fs.writeWithDirs( + path.join(legacy, "storage", "session", "info", "ses_legacy.json"), + JSON.stringify({ id: "ses_legacy", title: "legacy" }), + ) + yield* fs.writeWithDirs( + path.join(legacy, "storage", "session", "message", "ses_legacy", "msg_legacy.json"), + JSON.stringify({ role: "user", text: "hello" }), + ) - expect(await run(Storage.Service.use((svc) => svc.list(["session", project])))).toEqual([ - ["session", project, "ses_legacy"], - ]) - expect( - await run( - Storage.Service.use((svc) => svc.read<{ id: string; title: string }>(["session", project, "ses_legacy"])), - ), - ).toEqual({ - id: "ses_legacy", - title: "legacy", - }) - expect( - await run( - Storage.Service.use((svc) => - svc.read<{ role: string; text: string }>(["message", "ses_legacy", "msg_legacy"]), - ), - ), - ).toEqual({ - role: "user", - text: "hello", - }) - }) + yield* Effect.gen(function* () { + const svc = yield* Storage.Service + const projects = yield* svc.list(["project"]) + expect(projects).toHaveLength(1) + const project = projects[0]![1] - expect(await Bun.file(path.join(storage, "migration")).text()).toBe("2") - }) + expect(yield* svc.list(["session", project])).toEqual([["session", project, "ses_legacy"]]) + expect(yield* svc.read<{ id: string; title: string }>(["session", project, "ses_legacy"])).toEqual({ + id: "ses_legacy", + title: "legacy", + }) + expect(yield* svc.read<{ role: string; text: string }>(["message", "ses_legacy", "msg_legacy"])).toEqual({ + role: "user", + text: "hello", + }) + }).pipe(Effect.provide(remappedStorage(tmp))) - test("failed migrations do not advance the marker", async () => { - await using tmp = await tmpdir() - const storage = path.join(tmp.path, "storage") - const legacy = path.join(tmp.path, "project", "legacy") + expect(yield* fs.readFileString(path.join(storage, "migration"))).toBe("2") + }), + ) - await text(path.join(legacy, "storage", "session", "message", "probe", "0.json"), "{") + it.live("failed migrations do not advance the marker", () => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const tmp = yield* tmpdirScoped() + const storage = path.join(tmp, "storage") + const legacy = path.join(tmp, "project", "legacy") - await withStorage(tmp.path, async (run) => { - expect(await run(Storage.Service.use((svc) => svc.list(["project"])))).toEqual([]) - }) + yield* fs.writeWithDirs(path.join(legacy, "storage", "session", "message", "probe", "0.json"), "{") - expect(await exists(path.join(storage, "migration"))).toBe(false) - }) + yield* Effect.gen(function* () { + const svc = yield* Storage.Service + expect(yield* svc.list(["project"])).toEqual([]) + }).pipe(Effect.provide(remappedStorage(tmp))) + + const exit = yield* fs.access(path.join(storage, "migration")).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + }), + ) }) diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 19c8cfefd..03220ea3b 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -1,11 +1,29 @@ import { describe, expect, test } from "bun:test" import path from "path" import * as fs from "fs/promises" +import { Effect, ManagedRuntime, Layer } from "effect" import { ApplyPatchTool } from "../../src/tool/apply_patch" 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, + ), +) + const baseCtx = { sessionID: SessionID.make("ses_test"), messageID: MessageID.make(""), @@ -13,7 +31,7 @@ const baseCtx = { agent: "build", abort: AbortSignal.any([]), messages: [], - metadata: () => {}, + metadata: () => Effect.void, } type AskInput = { @@ -36,21 +54,23 @@ type AskInput = { } type ToolCtx = typeof baseCtx & { - ask: (input: AskInput) => Promise + ask: (input: AskInput) => Effect.Effect } const execute = async (params: { patchText: string }, ctx: ToolCtx) => { - const tool = await ApplyPatchTool.init() - return tool.execute(params, ctx) + const info = await runtime.runPromise(ApplyPatchTool) + const tool = await runtime.runPromise(info.init()) + return Effect.runPromise(tool.execute(params, ctx)) } const makeCtx = () => { const calls: AskInput[] = [] const ctx: ToolCtx = { ...baseCtx, - ask: async (input) => { - calls.push(input) - }, + ask: (input) => + Effect.sync(() => { + calls.push(input) + }), } return { ctx, calls } diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index e4ba881fb..839c066c6 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { Effect, Layer, ManagedRuntime } from "effect" import os from "os" import path from "path" import { Shell } from "../../src/shell/shell" @@ -7,8 +8,26 @@ 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" +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, + ), +) + +function initBash() { + return runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => info.init()))) +} const ctx = { sessionID: SessionID.make("ses_test"), @@ -17,8 +36,8 @@ const ctx = { agent: "build", abort: AbortSignal.any([]), messages: [], - metadata: () => {}, - ask: async () => {}, + metadata: () => Effect.void, + ask: () => Effect.void, } Shell.acceptable.reset() @@ -97,10 +116,11 @@ const each = (name: string, fn: (item: { label: string; shell: string }) => Prom const capture = (requests: Array>, stop?: Error) => ({ ...ctx, - ask: async (req: Omit) => { - requests.push(req) - if (stop) throw stop - }, + ask: (req: Omit) => + Effect.sync(() => { + requests.push(req) + if (stop) throw stop + }), }) const mustTruncate = (result: { @@ -118,13 +138,15 @@ describe("tool.bash", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() - const result = await bash.execute( - { - command: "echo test", - description: "Echo test message", - }, - ctx, + const bash = await initBash() + const result = await Effect.runPromise( + bash.execute( + { + command: "echo test", + description: "Echo test message", + }, + ctx, + ), ) expect(result.metadata.exit).toBe(0) expect(result.metadata.output).toContain("test") @@ -139,14 +161,16 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const requests: Array> = [] - await bash.execute( - { - command: "echo hello", - description: "Echo hello", - }, - capture(requests), + await Effect.runPromise( + bash.execute( + { + command: "echo hello", + description: "Echo hello", + }, + capture(requests), + ), ) expect(requests.length).toBe(1) expect(requests[0].permission).toBe("bash") @@ -160,14 +184,16 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const requests: Array> = [] - await bash.execute( - { - command: "echo foo && echo bar", - description: "Echo twice", - }, - capture(requests), + await Effect.runPromise( + bash.execute( + { + command: "echo foo && echo bar", + description: "Echo twice", + }, + capture(requests), + ), ) expect(requests.length).toBe(1) expect(requests[0].permission).toBe("bash") @@ -184,14 +210,16 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const requests: Array> = [] - await bash.execute( - { - command: "Write-Host foo; if ($?) { Write-Host bar }", - description: "Check PowerShell conditional", - }, - capture(requests), + await Effect.runPromise( + bash.execute( + { + command: "Write-Host foo; if ($?) { Write-Host bar }", + description: "Check PowerShell conditional", + }, + capture(requests), + ), ) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() @@ -208,18 +236,20 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const err = new Error("stop after permission") const requests: Array> = [] const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*" const want = process.platform === "win32" ? glob(path.join(process.env.WINDIR!, "*")) : "/etc/*" await expect( - bash.execute( - { - command: `cat ${file}`, - description: "Read wildcard path", - }, - capture(requests, err), + Effect.runPromise( + bash.execute( + { + command: `cat ${file}`, + description: "Read wildcard path", + }, + capture(requests, err), + ), ), ).rejects.toThrow(err.message) const extDirReq = requests.find((r) => r.permission === "external_directory") @@ -242,15 +272,17 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/") const requests: Array> = [] - await bash.execute( - { - command: `echo $(cat "${file}")`, - description: "Read nested bash file", - }, - capture(requests), + await Effect.runPromise( + bash.execute( + { + command: `echo $(cat "${file}")`, + description: "Read nested bash file", + }, + capture(requests), + ), ) const extDirReq = requests.find((r) => r.permission === "external_directory") const bashReq = requests.find((r) => r.permission === "bash") @@ -273,16 +305,18 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const err = new Error("stop after permission") const requests: Array> = [] await expect( - bash.execute( - { - command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`, - description: "Copy Windows ini", - }, - capture(requests, err), + Effect.runPromise( + bash.execute( + { + command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`, + description: "Copy Windows ini", + }, + capture(requests, err), + ), ), ).rejects.toThrow(err.message) const extDirReq = requests.find((r) => r.permission === "external_directory") @@ -301,15 +335,17 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const requests: Array> = [] const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini` - await bash.execute( - { - command: `Write-Output $(Get-Content ${file})`, - description: "Read nested PowerShell file", - }, - capture(requests), + await Effect.runPromise( + bash.execute( + { + command: `Write-Output $(Get-Content ${file})`, + description: "Read nested PowerShell file", + }, + capture(requests), + ), ) const extDirReq = requests.find((r) => r.permission === "external_directory") const bashReq = requests.find((r) => r.permission === "bash") @@ -331,16 +367,18 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const err = new Error("stop after permission") const requests: Array> = [] await expect( - bash.execute( - { - command: 'Get-Content "C:../outside.txt"', - description: "Read drive-relative file", - }, - capture(requests, err), + Effect.runPromise( + bash.execute( + { + command: 'Get-Content "C:../outside.txt"', + description: "Read drive-relative file", + }, + capture(requests, err), + ), ), ).rejects.toThrow(err.message) expect(requests[0]?.permission).toBe("external_directory") @@ -359,16 +397,18 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const err = new Error("stop after permission") const requests: Array> = [] await expect( - bash.execute( - { - command: 'Get-Content "$HOME/.ssh/config"', - description: "Read home config", - }, - capture(requests, err), + Effect.runPromise( + bash.execute( + { + command: 'Get-Content "$HOME/.ssh/config"', + description: "Read home config", + }, + capture(requests, err), + ), ), ).rejects.toThrow(err.message) expect(requests[0]?.permission).toBe("external_directory") @@ -388,16 +428,18 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const err = new Error("stop after permission") const requests: Array> = [] await expect( - bash.execute( - { - command: 'Get-Content "$PWD/../outside.txt"', - description: "Read pwd-relative file", - }, - capture(requests, err), + Effect.runPromise( + bash.execute( + { + command: 'Get-Content "$PWD/../outside.txt"', + description: "Read pwd-relative file", + }, + capture(requests, err), + ), ), ).rejects.toThrow(err.message) expect(requests[0]?.permission).toBe("external_directory") @@ -416,16 +458,18 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const err = new Error("stop after permission") const requests: Array> = [] await expect( - bash.execute( - { - command: 'Get-Content "$PSHOME/outside.txt"', - description: "Read pshome file", - }, - capture(requests, err), + Effect.runPromise( + bash.execute( + { + command: 'Get-Content "$PSHOME/outside.txt"', + description: "Read pshome file", + }, + capture(requests, err), + ), ), ).rejects.toThrow(err.message) expect(requests[0]?.permission).toBe("external_directory") @@ -448,17 +492,19 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const err = new Error("stop after permission") const requests: Array> = [] const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "") await expect( - bash.execute( - { - command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`, - description: "Read Windows ini with missing env", - }, - capture(requests, err), + Effect.runPromise( + bash.execute( + { + command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`, + description: "Read Windows ini with missing env", + }, + capture(requests, err), + ), ), ).rejects.toThrow(err.message) const extDirReq = requests.find((r) => r.permission === "external_directory") @@ -481,14 +527,16 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const requests: Array> = [] - await bash.execute( - { - command: "Get-Content $env:WINDIR/win.ini", - description: "Read Windows ini from env", - }, - capture(requests), + await Effect.runPromise( + bash.execute( + { + command: "Get-Content $env:WINDIR/win.ini", + description: "Read Windows ini from env", + }, + capture(requests), + ), ) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() @@ -508,16 +556,18 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const err = new Error("stop after permission") const requests: Array> = [] await expect( - bash.execute( - { - command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`, - description: "Read Windows ini from FileSystem provider", - }, - capture(requests, err), + Effect.runPromise( + bash.execute( + { + command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`, + description: "Read Windows ini from FileSystem provider", + }, + capture(requests, err), + ), ), ).rejects.toThrow(err.message) expect(requests[0]?.permission).toBe("external_directory") @@ -538,16 +588,18 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const err = new Error("stop after permission") const requests: Array> = [] await expect( - bash.execute( - { - command: "Get-Content ${env:WINDIR}/win.ini", - description: "Read Windows ini from braced env", - }, - capture(requests, err), + Effect.runPromise( + bash.execute( + { + command: "Get-Content ${env:WINDIR}/win.ini", + description: "Read Windows ini from braced env", + }, + capture(requests, err), + ), ), ).rejects.toThrow(err.message) expect(requests[0]?.permission).toBe("external_directory") @@ -568,14 +620,16 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const requests: Array> = [] - await bash.execute( - { - command: "Set-Location C:/Windows", - description: "Change location", - }, - capture(requests), + await Effect.runPromise( + bash.execute( + { + command: "Set-Location C:/Windows", + description: "Change location", + }, + capture(requests), + ), ) const extDirReq = requests.find((r) => r.permission === "external_directory") const bashReq = requests.find((r) => r.permission === "bash") @@ -597,14 +651,16 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const requests: Array> = [] - await bash.execute( - { - command: "Write-Output ('a' * 3)", - description: "Write repeated text", - }, - capture(requests), + await Effect.runPromise( + bash.execute( + { + command: "Write-Output ('a' * 3)", + description: "Write repeated text", + }, + capture(requests), + ), ) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() @@ -622,16 +678,18 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const err = new Error("stop after permission") const requests: Array> = [] await expect( - bash.execute( - { - command: "cd ../", - description: "Change to parent directory", - }, - capture(requests, err), + Effect.runPromise( + bash.execute( + { + command: "cd ../", + description: "Change to parent directory", + }, + capture(requests, err), + ), ), ).rejects.toThrow(err.message) const extDirReq = requests.find((r) => r.permission === "external_directory") @@ -645,17 +703,19 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const err = new Error("stop after permission") const requests: Array> = [] await expect( - bash.execute( - { - command: "echo ok", - workdir: os.tmpdir(), - description: "Echo from temp dir", - }, - capture(requests, err), + Effect.runPromise( + bash.execute( + { + command: "echo ok", + workdir: os.tmpdir(), + description: "Echo from temp dir", + }, + capture(requests, err), + ), ), ).rejects.toThrow(err.message) const extDirReq = requests.find((r) => r.permission === "external_directory") @@ -673,19 +733,21 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const want = Filesystem.normalizePathPattern(path.join(outerTmp.path, "*")) for (const dir of forms(outerTmp.path)) { const requests: Array> = [] await expect( - bash.execute( - { - command: "echo ok", - workdir: dir, - description: "Echo from external dir", - }, - capture(requests, err), + Effect.runPromise( + bash.execute( + { + command: "echo ok", + workdir: dir, + description: "Echo from external dir", + }, + capture(requests, err), + ), ), ).rejects.toThrow(err.message) @@ -707,18 +769,20 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const err = new Error("stop after permission") const requests: Array> = [] const want = glob(path.join(os.tmpdir(), "*")) await expect( - bash.execute( - { - command: "echo ok", - workdir: "/tmp", - description: "Echo from Git Bash tmp", - }, - capture(requests, err), + Effect.runPromise( + bash.execute( + { + command: "echo ok", + workdir: "/tmp", + description: "Echo from Git Bash tmp", + }, + capture(requests, err), + ), ), ).rejects.toThrow(err.message) expect(requests[0]).toMatchObject({ @@ -737,17 +801,19 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const err = new Error("stop after permission") const requests: Array> = [] const want = glob(path.join(os.tmpdir(), "*")) await expect( - bash.execute( - { - command: "cat /tmp/opencode-does-not-exist", - description: "Read Git Bash tmp file", - }, - capture(requests, err), + Effect.runPromise( + bash.execute( + { + command: "cat /tmp/opencode-does-not-exist", + description: "Read Git Bash tmp file", + }, + capture(requests, err), + ), ), ).rejects.toThrow(err.message) expect(requests[0]).toMatchObject({ @@ -772,17 +838,19 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const err = new Error("stop after permission") const requests: Array> = [] const filepath = path.join(outerTmp.path, "outside.txt") await expect( - bash.execute( - { - command: `cat ${filepath}`, - description: "Read external file", - }, - capture(requests, err), + Effect.runPromise( + bash.execute( + { + command: `cat ${filepath}`, + description: "Read external file", + }, + capture(requests, err), + ), ), ).rejects.toThrow(err.message) const extDirReq = requests.find((r) => r.permission === "external_directory") @@ -803,14 +871,16 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const requests: Array> = [] - await bash.execute( - { - command: `rm -rf ${path.join(tmp.path, "nested")}`, - description: "Remove nested dir", - }, - capture(requests), + await Effect.runPromise( + bash.execute( + { + command: `rm -rf ${path.join(tmp.path, "nested")}`, + description: "Remove nested dir", + }, + capture(requests), + ), ) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeUndefined() @@ -823,14 +893,16 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const requests: Array> = [] - await bash.execute( - { - command: "git log --oneline -5", - description: "Git log", - }, - capture(requests), + await Effect.runPromise( + bash.execute( + { + command: "git log --oneline -5", + description: "Git log", + }, + capture(requests), + ), ) expect(requests.length).toBe(1) expect(requests[0].always.length).toBeGreaterThan(0) @@ -844,14 +916,16 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const requests: Array> = [] - await bash.execute( - { - command: "cd .", - description: "Stay in current directory", - }, - capture(requests), + await Effect.runPromise( + bash.execute( + { + command: "cd .", + description: "Stay in current directory", + }, + capture(requests), + ), ) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeUndefined() @@ -864,13 +938,15 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const err = new Error("stop after permission") const requests: Array> = [] await expect( - bash.execute( - { command: "echo test > output.txt", description: "Redirect test output" }, - capture(requests, err), + Effect.runPromise( + bash.execute( + { command: "echo test > output.txt", description: "Redirect test output" }, + capture(requests, err), + ), ), ).rejects.toThrow(err.message) const bashReq = requests.find((r) => r.permission === "bash") @@ -885,9 +961,9 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const requests: Array> = [] - await bash.execute({ command: "ls -la", description: "List" }, capture(requests)) + await Effect.runPromise(bash.execute({ command: "ls -la", description: "List" }, capture(requests))) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() expect(bashReq!.always[0]).toBe("ls *") @@ -901,27 +977,29 @@ describe("tool.bash abort", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const controller = new AbortController() const collected: string[] = [] - const result = bash.execute( - { - command: `echo before && sleep 30`, - description: "Long running command", - }, - { - ...ctx, - abort: controller.signal, - metadata: (input) => { - const output = (input.metadata as { output?: string })?.output - if (output && output.includes("before") && !controller.signal.aborted) { - collected.push(output) - controller.abort() - } + const res = await Effect.runPromise( + bash.execute( + { + command: `echo before && sleep 30`, + description: "Long running command", }, - }, + { + ...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() + } + }), + }, + ), ) - const res = await result expect(res.output).toContain("before") expect(res.output).toContain("User aborted the command") expect(collected.length).toBeGreaterThan(0) @@ -933,14 +1011,16 @@ describe("tool.bash abort", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() - const result = await bash.execute( - { - command: `echo started && sleep 60`, - description: "Timeout test", - timeout: 500, - }, - ctx, + const bash = await initBash() + const result = await Effect.runPromise( + bash.execute( + { + command: `echo started && sleep 60`, + description: "Timeout test", + timeout: 500, + }, + ctx, + ), ) expect(result.output).toContain("started") expect(result.output).toContain("bash tool terminated command after exceeding timeout") @@ -952,13 +1032,15 @@ describe("tool.bash abort", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() - const result = await bash.execute( - { - command: `echo stdout_msg && echo stderr_msg >&2`, - description: "Stderr test", - }, - ctx, + const bash = await initBash() + const result = await Effect.runPromise( + bash.execute( + { + command: `echo stdout_msg && echo stderr_msg >&2`, + description: "Stderr test", + }, + ctx, + ), ) expect(result.output).toContain("stdout_msg") expect(result.output).toContain("stderr_msg") @@ -971,13 +1053,15 @@ describe("tool.bash abort", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() - const result = await bash.execute( - { - command: `exit 42`, - description: "Non-zero exit", - }, - ctx, + const bash = await initBash() + const result = await Effect.runPromise( + bash.execute( + { + command: `exit 42`, + description: "Non-zero exit", + }, + ctx, + ), ) expect(result.metadata.exit).toBe(42) }, @@ -988,20 +1072,23 @@ describe("tool.bash abort", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const updates: string[] = [] - const result = await bash.execute( - { - command: `echo first && sleep 0.1 && echo second`, - description: "Streaming test", - }, - { - ...ctx, - metadata: (input) => { - const output = (input.metadata as { output?: string })?.output - if (output) updates.push(output) + const result = await Effect.runPromise( + bash.execute( + { + command: `echo first && sleep 0.1 && echo second`, + description: "Streaming test", }, - }, + { + ...ctx, + metadata: (input) => + Effect.sync(() => { + const output = (input.metadata as { output?: string })?.output + if (output) updates.push(output) + }), + }, + ), ) expect(result.output).toContain("first") expect(result.output).toContain("second") @@ -1016,14 +1103,16 @@ describe("tool.bash truncation", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const lineCount = Truncate.MAX_LINES + 500 - const result = await bash.execute( - { - command: fill("lines", lineCount), - description: "Generate lines exceeding limit", - }, - ctx, + const result = await Effect.runPromise( + bash.execute( + { + command: fill("lines", lineCount), + description: "Generate lines exceeding limit", + }, + ctx, + ), ) mustTruncate(result) expect(result.output).toContain("truncated") @@ -1036,14 +1125,16 @@ describe("tool.bash truncation", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const byteCount = Truncate.MAX_BYTES + 10000 - const result = await bash.execute( - { - command: fill("bytes", byteCount), - description: "Generate bytes exceeding limit", - }, - ctx, + const result = await Effect.runPromise( + bash.execute( + { + command: fill("bytes", byteCount), + description: "Generate bytes exceeding limit", + }, + ctx, + ), ) mustTruncate(result) expect(result.output).toContain("truncated") @@ -1056,13 +1147,15 @@ describe("tool.bash truncation", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() - const result = await bash.execute( - { - command: "echo hello", - description: "Echo hello", - }, - ctx, + const bash = await initBash() + const result = await Effect.runPromise( + bash.execute( + { + command: "echo hello", + description: "Echo hello", + }, + ctx, + ), ) expect((result.metadata as { truncated?: boolean }).truncated).toBe(false) expect(result.output).toContain("hello") @@ -1074,14 +1167,16 @@ describe("tool.bash truncation", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await BashTool.init() + const bash = await initBash() const lineCount = Truncate.MAX_LINES + 100 - const result = await bash.execute( - { - command: fill("lines", lineCount), - description: "Generate lines for file check", - }, - ctx, + const result = await Effect.runPromise( + bash.execute( + { + command: fill("lines", lineCount), + description: "Generate lines for file check", + }, + ctx, + ), ) mustTruncate(result) diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 96d41400e..e3f28df35 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -1,10 +1,18 @@ -import { afterEach, describe, test, expect } from "bun:test" +import { afterAll, afterEach, describe, test, expect } from "bun:test" import path from "path" import fs from "fs/promises" +import { Effect, Layer, ManagedRuntime } from "effect" import { EditTool } from "../../src/tool/edit" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" 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 = { @@ -14,8 +22,8 @@ const ctx = { agent: "build", abort: AbortSignal.any([]), messages: [], - metadata: () => {}, - ask: async () => {}, + metadata: () => Effect.void, + ask: () => Effect.void, } afterEach(async () => { @@ -27,6 +35,36 @@ async function touch(file: string, time: number) { await fs.utimes(file, date, date) } +const runtime = ManagedRuntime.make( + Layer.mergeAll( + LSP.defaultLayer, + FileTime.defaultLayer, + AppFileSystem.defaultLayer, + Format.defaultLayer, + Bus.layer, + Truncate.defaultLayer, + Agent.defaultLayer, + ), +) + +afterAll(async () => { + await runtime.dispose() +}) + +const resolve = () => + runtime.runPromise( + Effect.gen(function* () { + const info = yield* EditTool + return yield* info.init() + }), + ) + +const readFileTime = (sessionID: SessionID, filepath: string) => + runtime.runPromise(FileTime.Service.use((ft) => ft.read(sessionID, filepath))) + +const subscribeBus = (def: D, callback: () => unknown) => + runtime.runPromise(Bus.Service.use((bus) => bus.subscribeCallback(def, callback))) + describe("tool.edit", () => { describe("creating new files", () => { test("creates new file when oldString is empty", async () => { @@ -36,14 +74,16 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const edit = await EditTool.init() - const result = await edit.execute( - { - filePath: filepath, - oldString: "", - newString: "new content", - }, - ctx, + const edit = await resolve() + const result = await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "", + newString: "new content", + }, + ctx, + ), ) expect(result.metadata.diff).toContain("new content") @@ -61,14 +101,16 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const edit = await EditTool.init() - await edit.execute( - { - filePath: filepath, - oldString: "", - newString: "nested file", - }, - ctx, + const edit = await resolve() + await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "", + newString: "nested file", + }, + ctx, + ), ) const content = await fs.readFile(filepath, "utf-8") @@ -84,21 +126,21 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const { Bus } = await import("../../src/bus") - const { File } = await import("../../src/file") const { FileWatcher } = await import("../../src/file/watcher") const events: string[] = [] - const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated")) + const unsubUpdated = await subscribeBus(FileWatcher.Event.Updated, () => events.push("updated")) - const edit = await EditTool.init() - await edit.execute( - { - filePath: filepath, - oldString: "", - newString: "content", - }, - ctx, + const edit = await resolve() + await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "", + newString: "content", + }, + ctx, + ), ) expect(events).toContain("updated") @@ -117,16 +159,18 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await readFileTime(ctx.sessionID, filepath) - const edit = await EditTool.init() - const result = await edit.execute( - { - filePath: filepath, - oldString: "old content", - newString: "new content", - }, - ctx, + const edit = await resolve() + const result = await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "old content", + newString: "new content", + }, + ctx, + ), ) expect(result.output).toContain("Edit applied successfully") @@ -144,17 +188,19 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await readFileTime(ctx.sessionID, filepath) - const edit = await EditTool.init() + const edit = await resolve() await expect( - edit.execute( - { - filePath: filepath, - oldString: "old", - newString: "new", - }, - ctx, + Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "old", + newString: "new", + }, + ctx, + ), ), ).rejects.toThrow("not found") }, @@ -169,15 +215,17 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const edit = await EditTool.init() + const edit = await resolve() await expect( - edit.execute( - { - filePath: filepath, - oldString: "same", - newString: "same", - }, - ctx, + Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "same", + newString: "same", + }, + ctx, + ), ), ).rejects.toThrow("identical") }, @@ -192,17 +240,19 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await readFileTime(ctx.sessionID, filepath) - const edit = await EditTool.init() + const edit = await resolve() await expect( - edit.execute( - { - filePath: filepath, - oldString: "not in file", - newString: "replacement", - }, - ctx, + Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "not in file", + newString: "replacement", + }, + ctx, + ), ), ).rejects.toThrow() }, @@ -217,15 +267,17 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const edit = await EditTool.init() + const edit = await resolve() await expect( - edit.execute( - { - filePath: filepath, - oldString: "content", - newString: "modified", - }, - ctx, + Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "content", + newString: "modified", + }, + ctx, + ), ), ).rejects.toThrow("You must read file") }, @@ -242,22 +294,24 @@ describe("tool.edit", () => { directory: tmp.path, fn: async () => { // Read first - await FileTime.read(ctx.sessionID, filepath) + await readFileTime(ctx.sessionID, filepath) // Simulate external modification await fs.writeFile(filepath, "modified externally", "utf-8") await touch(filepath, 2_000) // Try to edit with the new content - const edit = await EditTool.init() + const edit = await resolve() await expect( - edit.execute( - { - filePath: filepath, - oldString: "modified externally", - newString: "edited", - }, - ctx, + Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "modified externally", + newString: "edited", + }, + ctx, + ), ), ).rejects.toThrow("modified since it was last read") }, @@ -272,17 +326,19 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await readFileTime(ctx.sessionID, filepath) - const edit = await EditTool.init() - await edit.execute( - { - filePath: filepath, - oldString: "foo", - newString: "qux", - replaceAll: true, - }, - ctx, + const edit = await resolve() + await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "foo", + newString: "qux", + replaceAll: true, + }, + ctx, + ), ) const content = await fs.readFile(filepath, "utf-8") @@ -299,22 +355,23 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await readFileTime(ctx.sessionID, filepath) - const { Bus } = await import("../../src/bus") const { FileWatcher } = await import("../../src/file/watcher") const events: string[] = [] - const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated")) + const unsubUpdated = await subscribeBus(FileWatcher.Event.Updated, () => events.push("updated")) - const edit = await EditTool.init() - await edit.execute( - { - filePath: filepath, - oldString: "original", - newString: "modified", - }, - ctx, + const edit = await resolve() + await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "original", + newString: "modified", + }, + ctx, + ), ) expect(events).toContain("updated") @@ -333,16 +390,18 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await readFileTime(ctx.sessionID, filepath) - const edit = await EditTool.init() - await edit.execute( - { - filePath: filepath, - oldString: "line2", - newString: "new line 2\nextra line", - }, - ctx, + const edit = await resolve() + await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "line2", + newString: "new line 2\nextra line", + }, + ctx, + ), ) const content = await fs.readFile(filepath, "utf-8") @@ -359,16 +418,18 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await readFileTime(ctx.sessionID, filepath) - const edit = await EditTool.init() - await edit.execute( - { - filePath: filepath, - oldString: "old", - newString: "new", - }, - ctx, + const edit = await resolve() + await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "old", + newString: "new", + }, + ctx, + ), ) const content = await fs.readFile(filepath, "utf-8") @@ -385,15 +446,17 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const edit = await EditTool.init() + const edit = await resolve() await expect( - edit.execute( - { - filePath: filepath, - oldString: "", - newString: "", - }, - ctx, + Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "", + newString: "", + }, + ctx, + ), ), ).rejects.toThrow("identical") }, @@ -408,17 +471,19 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, dirpath) + await readFileTime(ctx.sessionID, dirpath) - const edit = await EditTool.init() + const edit = await resolve() await expect( - edit.execute( - { - filePath: dirpath, - oldString: "old", - newString: "new", - }, - ctx, + Effect.runPromise( + edit.execute( + { + filePath: dirpath, + oldString: "old", + newString: "new", + }, + ctx, + ), ), ).rejects.toThrow("directory") }, @@ -433,16 +498,18 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await readFileTime(ctx.sessionID, filepath) - const edit = await EditTool.init() - const result = await edit.execute( - { - filePath: filepath, - oldString: "line2", - newString: "new line a\nnew line b", - }, - ctx, + const edit = await resolve() + const result = await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "line2", + newString: "new line a\nnew line b", + }, + ctx, + ), ) expect(result.metadata.filediff).toBeDefined() @@ -502,17 +569,19 @@ describe("tool.edit", () => { return await Instance.provide({ directory: tmp.path, fn: async () => { - const edit = await EditTool.init() + const edit = await resolve() const filePath = path.join(tmp.path, "test.txt") - await FileTime.read(ctx.sessionID, filePath) - await edit.execute( - { - filePath, - oldString: input.oldString, - newString: input.newString, - replaceAll: input.replaceAll, - }, - ctx, + await readFileTime(ctx.sessionID, filePath) + await Effect.runPromise( + edit.execute( + { + filePath, + oldString: input.oldString, + newString: input.newString, + replaceAll: input.replaceAll, + }, + ctx, + ), ) return await Bun.file(filePath).text() }, @@ -645,30 +714,34 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await readFileTime(ctx.sessionID, filepath) - const edit = await EditTool.init() + const edit = await resolve() // Two concurrent edits - const promise1 = edit.execute( - { - filePath: filepath, - oldString: "0", - newString: "1", - }, - ctx, + const promise1 = Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "0", + newString: "1", + }, + ctx, + ), ) // Need to read again since FileTime tracks per-session - await FileTime.read(ctx.sessionID, filepath) + await readFileTime(ctx.sessionID, filepath) - const promise2 = edit.execute( - { - filePath: filepath, - oldString: "0", - newString: "2", - }, - ctx, + const promise2 = Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "0", + newString: "2", + }, + ctx, + ), ) // Both should complete without error (though one might fail due to content mismatch) diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index cf95eaf4b..727ab74f1 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import path from "path" +import { Effect } from "effect" import type { Tool } from "../../src/tool/tool" import { Instance } from "../../src/project/instance" import { assertExternalDirectory } from "../../src/tool/external-directory" @@ -15,21 +16,27 @@ const baseCtx: Omit = { agent: "build", abort: AbortSignal.any([]), messages: [], - metadata: () => {}, + metadata: () => Effect.void, } const glob = (p: string) => process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/") +function makeCtx() { + const requests: Array> = [] + const ctx: Tool.Context = { + ...baseCtx, + ask: (req) => + Effect.sync(() => { + requests.push(req) + }), + } + return { requests, ctx } +} + describe("tool.assertExternalDirectory", () => { test("no-ops for empty target", async () => { - const requests: Array> = [] - const ctx: Tool.Context = { - ...baseCtx, - ask: async (req) => { - requests.push(req) - }, - } + const { requests, ctx } = makeCtx() await Instance.provide({ directory: "/tmp", @@ -42,13 +49,7 @@ describe("tool.assertExternalDirectory", () => { }) test("no-ops for paths inside Instance.directory", async () => { - const requests: Array> = [] - const ctx: Tool.Context = { - ...baseCtx, - ask: async (req) => { - requests.push(req) - }, - } + const { requests, ctx } = makeCtx() await Instance.provide({ directory: "/tmp/project", @@ -61,13 +62,7 @@ describe("tool.assertExternalDirectory", () => { }) test("asks with a single canonical glob", async () => { - const requests: Array> = [] - const ctx: Tool.Context = { - ...baseCtx, - ask: async (req) => { - requests.push(req) - }, - } + const { requests, ctx } = makeCtx() const directory = "/tmp/project" const target = "/tmp/outside/file.txt" @@ -87,13 +82,7 @@ describe("tool.assertExternalDirectory", () => { }) test("uses target directory when kind=directory", async () => { - const requests: Array> = [] - const ctx: Tool.Context = { - ...baseCtx, - ask: async (req) => { - requests.push(req) - }, - } + const { requests, ctx } = makeCtx() const directory = "/tmp/project" const target = "/tmp/outside" @@ -113,13 +102,7 @@ describe("tool.assertExternalDirectory", () => { }) test("skips prompting when bypass=true", async () => { - const requests: Array> = [] - const ctx: Tool.Context = { - ...baseCtx, - ask: async (req) => { - requests.push(req) - }, - } + const { requests, ctx } = makeCtx() await Instance.provide({ directory: "/tmp/project", @@ -133,13 +116,7 @@ describe("tool.assertExternalDirectory", () => { if (process.platform === "win32") { test("normalizes Windows path variants to one glob", async () => { - const requests: Array> = [] - const ctx: Tool.Context = { - ...baseCtx, - ask: async (req) => { - requests.push(req) - }, - } + const { requests, ctx } = makeCtx() await using outerTmp = await tmpdir({ init: async (dir) => { @@ -169,13 +146,7 @@ describe("tool.assertExternalDirectory", () => { }) test("uses drive root glob for root files", async () => { - const requests: Array> = [] - const ctx: Tool.Context = { - ...baseCtx, - ask: async (req) => { - requests.push(req) - }, - } + const { requests, ctx } = makeCtx() await using tmp = await tmpdir({ git: true }) const root = path.parse(tmp.path).root diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index e03b1752e..4715ad925 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -1,9 +1,21 @@ import { describe, expect, test } from "bun:test" import path from "path" +import { Effect, Layer, ManagedRuntime } from "effect" import { GrepTool } from "../../src/tool/grep" 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), +) + +function initGrep() { + return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => info.init()))) +} const ctx = { sessionID: SessionID.make("ses_test"), @@ -12,8 +24,8 @@ const ctx = { agent: "build", abort: AbortSignal.any([]), messages: [], - metadata: () => {}, - ask: async () => {}, + metadata: () => Effect.void, + ask: () => Effect.void, } const projectRoot = path.join(__dirname, "../..") @@ -23,14 +35,16 @@ describe("tool.grep", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const grep = await GrepTool.init() - const result = await grep.execute( - { - pattern: "export", - path: path.join(projectRoot, "src/tool"), - include: "*.ts", - }, - ctx, + const grep = await initGrep() + const result = await Effect.runPromise( + grep.execute( + { + pattern: "export", + path: path.join(projectRoot, "src/tool"), + include: "*.ts", + }, + ctx, + ), ) expect(result.metadata.matches).toBeGreaterThan(0) expect(result.output).toContain("Found") @@ -47,13 +61,15 @@ describe("tool.grep", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const grep = await GrepTool.init() - const result = await grep.execute( - { - pattern: "xyznonexistentpatternxyz123", - path: tmp.path, - }, - ctx, + const grep = await initGrep() + const result = await Effect.runPromise( + grep.execute( + { + pattern: "xyznonexistentpatternxyz123", + path: tmp.path, + }, + ctx, + ), ) expect(result.metadata.matches).toBe(0) expect(result.output).toBe("No files found") @@ -72,13 +88,15 @@ describe("tool.grep", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const grep = await GrepTool.init() - const result = await grep.execute( - { - pattern: "line", - path: tmp.path, - }, - ctx, + const grep = await initGrep() + const result = await Effect.runPromise( + grep.execute( + { + pattern: "line", + path: tmp.path, + }, + ctx, + ), ) expect(result.metadata.matches).toBeGreaterThan(0) }, diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index f1d9492ca..eb69f1d96 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -4,7 +4,9 @@ import { Tool } from "../../src/tool/tool" import { QuestionTool } from "../../src/tool/question" import { Question } from "../../src/question" import { SessionID, MessageID } from "../../src/session/schema" +import { Agent } from "../../src/agent/agent" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { Truncate } from "../../src/tool/truncate" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -15,11 +17,13 @@ const ctx = { agent: "test-agent", abort: AbortSignal.any([]), messages: [], - metadata: () => {}, - ask: async () => {}, + metadata: () => Effect.void, + ask: () => Effect.void, } -const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect( + Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer), +) const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) { for (;;) { @@ -36,7 +40,7 @@ describe("tool.question", () => { Effect.gen(function* () { const question = yield* Question.Service const toolInfo = yield* QuestionTool - const tool = yield* Effect.promise(() => toolInfo.init()) + const tool = yield* toolInfo.init() const questions = [ { question: "What is your favorite color?", @@ -49,7 +53,7 @@ describe("tool.question", () => { }, ] - const fiber = yield* Effect.promise(() => tool.execute({ questions }, ctx)).pipe(Effect.forkScoped) + const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) const item = yield* pending(question) yield* question.reply({ requestID: item.id, answers: [["Red"]] }) @@ -64,7 +68,7 @@ describe("tool.question", () => { Effect.gen(function* () { const question = yield* Question.Service const toolInfo = yield* QuestionTool - const tool = yield* Effect.promise(() => toolInfo.init()) + const tool = yield* toolInfo.init() const questions = [ { question: "What is your favorite animal?", @@ -73,7 +77,7 @@ describe("tool.question", () => { }, ] - const fiber = yield* Effect.promise(() => tool.execute({ questions }, ctx)).pipe(Effect.forkScoped) + const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) const item = yield* pending(question) yield* question.reply({ requestID: item.id, answers: [["Dog"]] }) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 12345266b..2064193d5 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -11,6 +11,7 @@ import { Instance } from "../../src/project/instance" import { SessionID, MessageID } from "../../src/session/schema" import { Instruction } from "../../src/session/instruction" import { ReadTool } from "../../src/tool/read" +import { Truncate } from "../../src/tool/truncate" import { Tool } from "../../src/tool/tool" import { Filesystem } from "../../src/util/filesystem" import { provideInstance, tmpdirScoped } from "../fixture/fixture" @@ -29,8 +30,8 @@ const ctx = { agent: "build", abort: AbortSignal.any([]), messages: [], - metadata: () => {}, - ask: async () => {}, + metadata: () => Effect.void, + ask: () => Effect.void, } const it = testEffect( @@ -41,12 +42,13 @@ const it = testEffect( FileTime.defaultLayer, Instruction.defaultLayer, LSP.defaultLayer, + Truncate.defaultLayer, ), ) const init = Effect.fn("ReadToolTest.init")(function* () { const info = yield* ReadTool - return yield* Effect.promise(() => info.init()) + return yield* info.init() }) const run = Effect.fn("ReadToolTest.run")(function* ( @@ -54,7 +56,7 @@ const run = Effect.fn("ReadToolTest.run")(function* ( next: Tool.Context = ctx, ) { const tool = yield* init() - return yield* Effect.promise(() => tool.execute(args, next)) + return yield* tool.execute(args, next) }) const exec = Effect.fn("ReadToolTest.exec")(function* ( @@ -95,9 +97,10 @@ const asks = () => { items, next: { ...ctx, - ask: async (req: Omit) => { - items.push(req) - }, + ask: (req: Omit) => + Effect.sync(() => { + items.push(req) + }), }, } } @@ -226,17 +229,18 @@ describe("tool.read env file permissions", () => { let asked = false const next = { ...ctx, - ask: async (req: Omit) => { - for (const pattern of req.patterns) { - const rule = Permission.evaluate(req.permission, pattern, info.permission) - if (rule.action === "ask" && req.permission === "read") { - asked = true + ask: (req: Omit) => + Effect.sync(() => { + for (const pattern of req.patterns) { + const rule = Permission.evaluate(req.permission, pattern, info.permission) + if (rule.action === "ask" && req.permission === "read") { + asked = true + } + if (rule.action === "deny") { + throw new Permission.DeniedError({ ruleset: info.permission }) + } } - if (rule.action === "deny") { - throw new Permission.DeniedError({ ruleset: info.permission }) - } - } - }, + }), } yield* run({ filePath: path.join(dir, filename) }, next) diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index e6269a4f3..a3873dbeb 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -1,11 +1,16 @@ -import { Effect } from "effect" +import { Effect, Layer, ManagedRuntime } from "effect" +import { Agent } from "../../src/agent/agent" +import { Skill } from "../../src/skill" +import { Ripgrep } from "../../src/file/ripgrep" +import { Truncate } from "../../src/tool/truncate" import { afterEach, describe, expect, test } from "bun:test" import path from "path" import { pathToFileURL } from "url" import type { Permission } from "../../src/permission" import type { Tool } from "../../src/tool/tool" import { Instance } from "../../src/project/instance" -import { SkillTool, SkillDescription } from "../../src/tool/skill" +import { SkillTool } from "../../src/tool/skill" +import { ToolRegistry } from "../../src/tool/registry" import { tmpdir } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" @@ -16,7 +21,7 @@ const baseCtx: Omit = { agent: "build", abort: AbortSignal.any([]), messages: [], - metadata: () => {}, + metadata: () => Effect.void, } afterEach(async () => { @@ -49,9 +54,11 @@ description: Skill for tool tests. await Instance.provide({ directory: tmp.path, fn: async () => { - const desc = await Effect.runPromise( - SkillDescription({ name: "build", mode: "primary" as const, permission: [], options: {} }), - ) + const desc = await ToolRegistry.tools({ + providerID: "opencode" as any, + modelID: "gpt-5" as any, + agent: { name: "build", mode: "primary" as const, permission: [], options: {} }, + }).then((tools) => tools.find((tool) => tool.id === SkillTool.id)?.description ?? "") expect(desc).toContain(`**tool-skill**: Skill for tool tests.`) }, }) @@ -92,8 +99,14 @@ description: ${description} directory: tmp.path, fn: async () => { const agent = { name: "build", mode: "primary" as const, permission: [], options: {} } - const first = await Effect.runPromise(SkillDescription(agent)) - const second = await Effect.runPromise(SkillDescription(agent)) + const load = () => + ToolRegistry.tools({ + providerID: "opencode" as any, + modelID: "gpt-5" as any, + agent, + }).then((tools) => tools.find((tool) => tool.id === SkillTool.id)?.description ?? "") + const first = await load() + const second = await load() expect(first).toBe(second) @@ -139,16 +152,21 @@ Use this skill. await Instance.provide({ directory: tmp.path, fn: async () => { - const tool = await SkillTool.init() + const runtime = ManagedRuntime.make( + Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer), + ) + const info = await runtime.runPromise(SkillTool) + const tool = await runtime.runPromise(info.init()) const requests: Array> = [] const ctx: Tool.Context = { ...baseCtx, - ask: async (req) => { - requests.push(req) - }, + ask: (req) => + Effect.sync(() => { + requests.push(req) + }), } - const result = await tool.execute({ name: "tool-skill" }, ctx) + const result = await runtime.runPromise(tool.execute({ name: "tool-skill" }, ctx)) const dir = path.join(tmp.path, ".opencode", "skill", "tool-skill") const file = path.resolve(dir, "scripts", "demo.txt") diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index fe936a242..436c46490 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,50 +1,387 @@ -import { Effect } from "effect" -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" import { Agent } from "../../src/agent/agent" +import { Config } from "../../src/config/config" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Instance } from "../../src/project/instance" -import { TaskDescription } from "../../src/tool/task" -import { tmpdir } from "../fixture/fixture" +import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import type { SessionPrompt } from "../../src/session/prompt" +import { MessageID, PartID } from "../../src/session/schema" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { TaskTool, type TaskPromptOps } from "../../src/tool/task" +import { Truncate } from "../../src/tool/truncate" +import { ToolRegistry } from "../../src/tool/registry" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" afterEach(async () => { await Instance.disposeAll() }) +const ref = { + providerID: ProviderID.make("test"), + modelID: ModelID.make("test-model"), +} + +const it = testEffect( + Layer.mergeAll( + Agent.defaultLayer, + Config.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Session.defaultLayer, + Truncate.defaultLayer, + ToolRegistry.defaultLayer, + ), +) + +const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") { + const session = yield* Session.Service + const chat = yield* session.create({ title }) + const user = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: chat.id, + agent: "build", + model: ref, + time: { created: Date.now() }, + }) + const assistant: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + parentID: user.id, + sessionID: chat.id, + mode: "build", + agent: "build", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now() }, + } + yield* session.updateMessage(assistant) + return { chat, assistant } +}) + +function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps { + return { + cancel() {}, + resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), + prompt: (input) => + Effect.sync(() => { + opts?.onPrompt?.(input) + return reply(input, opts?.text ?? "done") + }), + } +} + +function reply(input: Parameters[0], text: string): MessageV2.WithParts { + const id = MessageID.ascending() + return { + info: { + id, + role: "assistant", + parentID: input.messageID ?? MessageID.ascending(), + sessionID: input.sessionID, + mode: input.agent ?? "general", + agent: input.agent ?? "general", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: input.model?.modelID ?? ref.modelID, + providerID: input.model?.providerID ?? ref.providerID, + time: { created: Date.now() }, + finish: "stop", + }, + parts: [ + { + id: PartID.ascending(), + messageID: id, + sessionID: input.sessionID, + type: "text", + text, + }, + ], + } +} + describe("tool.task", () => { - test("description sorts subagents by name and is stable across calls", async () => { - await using tmp = await tmpdir({ - config: { - agent: { - zebra: { - description: "Zebra agent", - mode: "subagent", - }, - alpha: { - description: "Alpha agent", - mode: "subagent", + it.live("description sorts subagents by name and is stable across calls", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const agent = yield* Agent.Service + const build = yield* agent.get("build") + const registry = yield* ToolRegistry.Service + const get = Effect.fnUntraced(function* () { + const tools = yield* registry.tools({ ...ref, agent: build }) + return tools.find((tool) => tool.id === TaskTool.id)?.description ?? "" + }) + const first = yield* get() + const second = yield* get() + + expect(first).toBe(second) + + const alpha = first.indexOf("- alpha: Alpha agent") + const explore = first.indexOf("- explore:") + const general = first.indexOf("- general:") + const zebra = first.indexOf("- zebra: Zebra agent") + + expect(alpha).toBeGreaterThan(-1) + expect(explore).toBeGreaterThan(alpha) + expect(general).toBeGreaterThan(explore) + expect(zebra).toBeGreaterThan(general) + }), + { + config: { + agent: { + zebra: { + description: "Zebra agent", + mode: "subagent", + }, + alpha: { + description: "Alpha agent", + mode: "subagent", + }, }, }, }, - }) + ), + ) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const agent = { name: "build", mode: "primary" as const, permission: [], options: {} } - const first = await Effect.runPromise(TaskDescription(agent)) - const second = await Effect.runPromise(TaskDescription(agent)) + it.live("description hides denied subagents for the caller", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const agent = yield* Agent.Service + const build = yield* agent.get("build") + const registry = yield* ToolRegistry.Service + const description = + (yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? "" - expect(first).toBe(second) - - const alpha = first.indexOf("- alpha: Alpha agent") - const explore = first.indexOf("- explore:") - const general = first.indexOf("- general:") - const zebra = first.indexOf("- zebra: Zebra agent") - - expect(alpha).toBeGreaterThan(-1) - expect(explore).toBeGreaterThan(alpha) - expect(general).toBeGreaterThan(explore) - expect(zebra).toBeGreaterThan(general) + expect(description).toContain("- alpha: Alpha agent") + expect(description).not.toContain("- zebra: Zebra agent") + }), + { + config: { + permission: { + task: { + "*": "allow", + zebra: "deny", + }, + }, + agent: { + zebra: { + description: "Zebra agent", + mode: "subagent", + }, + alpha: { + description: "Alpha agent", + mode: "subagent", + }, + }, + }, }, - }) - }) + ), + ) + + it.live("execute resumes an existing task session from task_id", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" }) + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + task_id: child.id, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const kids = yield* sessions.children(chat.id) + expect(kids).toHaveLength(1) + expect(kids[0]?.id).toBe(child.id) + expect(result.metadata.sessionId).toBe(child.id) + expect(result.output).toContain(`task_id: ${child.id}`) + expect(seen?.sessionID).toBe(child.id) + }), + ), + ) + + it.live("execute asks by default and skips checks when bypassed", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const calls: unknown[] = [] + const promptOps = stubOps() + + const exec = (extra?: Record) => + def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps, ...extra }, + messages: [], + metadata: () => Effect.void, + ask: (input) => + Effect.sync(() => { + calls.push(input) + }), + }, + ) + + yield* exec() + yield* exec({ bypassAgentCheck: true }) + + expect(calls).toHaveLength(1) + expect(calls[0]).toEqual({ + permission: "task", + patterns: ["general"], + always: ["*"], + metadata: { + description: "inspect bug", + subagent_type: "general", + }, + }) + }), + ), + ) + + it.live("execute creates a child when task_id does not exist", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + task_id: "ses_missing", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const kids = yield* sessions.children(chat.id) + expect(kids).toHaveLength(1) + expect(kids[0]?.id).toBe(result.metadata.sessionId) + expect(result.metadata.sessionId).not.toBe("ses_missing") + expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`) + expect(seen?.sessionID).toBe(result.metadata.sessionId) + }), + ), + ) + + it.live("execute shapes child permissions for task, todowrite, and primary tools", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "reviewer", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const child = yield* sessions.get(result.metadata.sessionId) + expect(child.parentID).toBe(chat.id) + expect(child.permission).toEqual([ + { + permission: "todowrite", + pattern: "*", + action: "deny", + }, + { + permission: "bash", + pattern: "*", + action: "allow", + }, + { + permission: "read", + pattern: "*", + action: "allow", + }, + ]) + expect(seen?.tools).toEqual({ + todowrite: false, + bash: false, + read: false, + }) + }), + { + config: { + agent: { + reviewer: { + mode: "subagent", + permission: { + task: "allow", + }, + }, + }, + experimental: { + primary_tools: ["bash", "read"], + }, + }, + }, + ), + ) }) diff --git a/packages/opencode/test/tool/tool-define.test.ts b/packages/opencode/test/tool/tool-define.test.ts index 2ea6d56a5..b8003e475 100644 --- a/packages/opencode/test/tool/tool-define.test.ts +++ b/packages/opencode/test/tool/tool-define.test.ts @@ -1,6 +1,11 @@ import { describe, test, expect } from "bun:test" +import { Effect, Layer, ManagedRuntime } from "effect" import z from "zod" +import { Agent } from "../../src/agent/agent" import { Tool } from "../../src/tool/tool" +import { Truncate } from "../../src/tool/truncate" + +const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer)) const params = z.object({ input: z.string() }) @@ -8,9 +13,9 @@ function makeTool(id: string, executeFn?: () => void) { return { description: "test tool", parameters: params, - async execute() { + execute() { executeFn?.() - return { title: "test", output: "ok", metadata: {} } + return Effect.succeed({ title: "test", output: "ok", metadata: {} }) }, } } @@ -20,29 +25,34 @@ describe("Tool.define", () => { const original = makeTool("test") const originalExecute = original.execute - const tool = Tool.define("test-tool", original) + const info = await runtime.runPromise(Tool.define("test-tool", Effect.succeed(original))) - await tool.init() - await tool.init() - await tool.init() + await Effect.runPromise(info.init()) + await Effect.runPromise(info.init()) + await Effect.runPromise(info.init()) expect(original.execute).toBe(originalExecute) }) - test("function-defined tool returns fresh objects and is unaffected", async () => { - const tool = Tool.define("test-fn-tool", () => Promise.resolve(makeTool("test"))) + test("effect-defined tool returns fresh objects and is unaffected", async () => { + const info = await runtime.runPromise( + Tool.define( + "test-fn-tool", + Effect.succeed(() => Effect.succeed(makeTool("test"))), + ), + ) - const first = await tool.init() - const second = await tool.init() + const first = await Effect.runPromise(info.init()) + const second = await Effect.runPromise(info.init()) expect(first).not.toBe(second) }) test("object-defined tool returns distinct objects per init() call", async () => { - const tool = Tool.define("test-copy", makeTool("test")) + const info = await runtime.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test")))) - const first = await tool.init() - const second = await tool.init() + const first = await Effect.runPromise(info.init()) + const second = await Effect.runPromise(info.init()) expect(first).not.toBe(second) }) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 9ec5b7840..493cd9d7e 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect } from "bun:test" import { NodeFileSystem } from "@effect/platform-node" import { Effect, FileSystem, Layer } from "effect" -import { Truncate, Truncate as TruncateSvc } from "../../src/tool/truncate" +import { Truncate } from "../../src/tool/truncate" import { Identifier } from "../../src/id/id" import { Process } from "../../src/util/process" import { Filesystem } from "../../src/util/filesystem" @@ -12,120 +12,155 @@ import { writeFileStringScoped } from "../lib/filesystem" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") const ROOT = path.resolve(import.meta.dir, "..", "..") +const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer)) + describe("Truncate", () => { describe("output", () => { - test("truncates large json file by bytes", async () => { - const content = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")) - const result = await Truncate.output(content) + it.live("truncates large json file by bytes", () => + Effect.gen(function* () { + const svc = yield* Truncate.Service + const content = yield* Effect.promise(() => Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json"))) + const result = yield* svc.output(content) - expect(result.truncated).toBe(true) - expect(result.content).toContain("truncated...") - if (result.truncated) expect(result.outputPath).toBeDefined() - }) + expect(result.truncated).toBe(true) + expect(result.content).toContain("truncated...") + if (result.truncated) expect(result.outputPath).toBeDefined() + }), + ) - test("returns content unchanged when under limits", async () => { - const content = "line1\nline2\nline3" - const result = await Truncate.output(content) + it.live("returns content unchanged when under limits", () => + Effect.gen(function* () { + const svc = yield* Truncate.Service + const content = "line1\nline2\nline3" + const result = yield* svc.output(content) - expect(result.truncated).toBe(false) - expect(result.content).toBe(content) - }) + expect(result.truncated).toBe(false) + expect(result.content).toBe(content) + }), + ) - test("truncates by line count", async () => { - const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") - const result = await Truncate.output(lines, { maxLines: 10 }) + it.live("truncates by line count", () => + Effect.gen(function* () { + const svc = yield* Truncate.Service + const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") + const result = yield* svc.output(lines, { maxLines: 10 }) - expect(result.truncated).toBe(true) - expect(result.content).toContain("...90 lines truncated...") - }) + expect(result.truncated).toBe(true) + expect(result.content).toContain("...90 lines truncated...") + }), + ) - test("truncates by byte count", async () => { - const content = "a".repeat(1000) - const result = await Truncate.output(content, { maxBytes: 100 }) + it.live("truncates by byte count", () => + Effect.gen(function* () { + const svc = yield* Truncate.Service + const content = "a".repeat(1000) + const result = yield* svc.output(content, { maxBytes: 100 }) - expect(result.truncated).toBe(true) - expect(result.content).toContain("truncated...") - }) + expect(result.truncated).toBe(true) + expect(result.content).toContain("truncated...") + }), + ) - test("truncates from head by default", async () => { - const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n") - const result = await Truncate.output(lines, { maxLines: 3 }) + it.live("truncates from head by default", () => + Effect.gen(function* () { + const svc = yield* Truncate.Service + const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n") + const result = yield* svc.output(lines, { maxLines: 3 }) - expect(result.truncated).toBe(true) - expect(result.content).toContain("line0") - expect(result.content).toContain("line1") - expect(result.content).toContain("line2") - expect(result.content).not.toContain("line9") - }) + expect(result.truncated).toBe(true) + expect(result.content).toContain("line0") + expect(result.content).toContain("line1") + expect(result.content).toContain("line2") + expect(result.content).not.toContain("line9") + }), + ) - test("truncates from tail when direction is tail", async () => { - const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n") - const result = await Truncate.output(lines, { maxLines: 3, direction: "tail" }) + it.live("truncates from tail when direction is tail", () => + Effect.gen(function* () { + const svc = yield* Truncate.Service + const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n") + const result = yield* svc.output(lines, { maxLines: 3, direction: "tail" }) - expect(result.truncated).toBe(true) - expect(result.content).toContain("line7") - expect(result.content).toContain("line8") - expect(result.content).toContain("line9") - expect(result.content).not.toContain("line0") - }) + expect(result.truncated).toBe(true) + expect(result.content).toContain("line7") + expect(result.content).toContain("line8") + expect(result.content).toContain("line9") + expect(result.content).not.toContain("line0") + }), + ) test("uses default MAX_LINES and MAX_BYTES", () => { expect(Truncate.MAX_LINES).toBe(2000) expect(Truncate.MAX_BYTES).toBe(50 * 1024) }) - test("large single-line file truncates with byte message", async () => { - const content = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")) - const result = await Truncate.output(content) + it.live("large single-line file truncates with byte message", () => + Effect.gen(function* () { + const svc = yield* Truncate.Service + const content = yield* Effect.promise(() => Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json"))) + const result = yield* svc.output(content) - expect(result.truncated).toBe(true) - expect(result.content).toContain("bytes truncated...") - expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES) - }) + expect(result.truncated).toBe(true) + expect(result.content).toContain("bytes truncated...") + expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES) + }), + ) - test("writes full output to file when truncated", async () => { - const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") - const result = await Truncate.output(lines, { maxLines: 10 }) + it.live("writes full output to file when truncated", () => + Effect.gen(function* () { + const svc = yield* Truncate.Service + const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") + const result = yield* svc.output(lines, { maxLines: 10 }) - expect(result.truncated).toBe(true) - expect(result.content).toContain("The tool call succeeded but the output was truncated") - expect(result.content).toContain("Grep") - if (!result.truncated) throw new Error("expected truncated") - expect(result.outputPath).toBeDefined() - expect(result.outputPath).toContain("tool_") + expect(result.truncated).toBe(true) + expect(result.content).toContain("The tool call succeeded but the output was truncated") + expect(result.content).toContain("Grep") + if (!result.truncated) throw new Error("expected truncated") + expect(result.outputPath).toBeDefined() + expect(result.outputPath).toContain("tool_") - const written = await Filesystem.readText(result.outputPath!) - expect(written).toBe(lines) - }) + const written = yield* Effect.promise(() => Filesystem.readText(result.outputPath!)) + expect(written).toBe(lines) + }), + ) - test("suggests Task tool when agent has task permission", async () => { - const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") - const agent = { permission: [{ permission: "task", pattern: "*", action: "allow" as const }] } - const result = await Truncate.output(lines, { maxLines: 10 }, agent as any) + it.live("suggests Task tool when agent has task permission", () => + Effect.gen(function* () { + const svc = yield* Truncate.Service + const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") + const agent = { permission: [{ permission: "task", pattern: "*", action: "allow" as const }] } + const result = yield* svc.output(lines, { maxLines: 10 }, agent as any) - expect(result.truncated).toBe(true) - expect(result.content).toContain("Grep") - expect(result.content).toContain("Task tool") - }) + expect(result.truncated).toBe(true) + expect(result.content).toContain("Grep") + expect(result.content).toContain("Task tool") + }), + ) - test("omits Task tool hint when agent lacks task permission", async () => { - const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") - const agent = { permission: [{ permission: "task", pattern: "*", action: "deny" as const }] } - const result = await Truncate.output(lines, { maxLines: 10 }, agent as any) + it.live("omits Task tool hint when agent lacks task permission", () => + Effect.gen(function* () { + const svc = yield* Truncate.Service + const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") + const agent = { permission: [{ permission: "task", pattern: "*", action: "deny" as const }] } + const result = yield* svc.output(lines, { maxLines: 10 }, agent as any) - expect(result.truncated).toBe(true) - expect(result.content).toContain("Grep") - expect(result.content).not.toContain("Task tool") - }) + expect(result.truncated).toBe(true) + expect(result.content).toContain("Grep") + expect(result.content).not.toContain("Task tool") + }), + ) - test("does not write file when not truncated", async () => { - const content = "short content" - const result = await Truncate.output(content) + it.live("does not write file when not truncated", () => + Effect.gen(function* () { + const svc = yield* Truncate.Service + const content = "short content" + const result = yield* svc.output(content) - expect(result.truncated).toBe(false) - if (result.truncated) throw new Error("expected not truncated") - expect("outputPath" in result).toBe(false) - }) + expect(result.truncated).toBe(false) + if (result.truncated) throw new Error("expected not truncated") + expect("outputPath" in result).toBe(false) + }), + ) test("loads truncate effect in a fresh process", async () => { const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], { @@ -138,10 +173,10 @@ describe("Truncate", () => { describe("cleanup", () => { const DAY_MS = 24 * 60 * 60 * 1000 - const it = testEffect(Layer.mergeAll(TruncateSvc.defaultLayer, NodeFileSystem.layer)) it.live("deletes files older than 7 days and preserves recent files", () => Effect.gen(function* () { + const svc = yield* Truncate.Service const fs = yield* FileSystem.FileSystem yield* fs.makeDirectory(Truncate.DIR, { recursive: true }) @@ -151,7 +186,7 @@ describe("Truncate", () => { yield* writeFileStringScoped(old, "old content") yield* writeFileStringScoped(recent, "recent content") - yield* TruncateSvc.Service.use((s) => s.cleanup()) + yield* svc.cleanup() expect(yield* fs.exists(old)).toBe(false) expect(yield* fs.exists(recent)).toBe(true) diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index 5233f1081..7d2ff1dca 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test" import path from "path" +import { Effect, Layer } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import { Agent } from "../../src/agent/agent" +import { Truncate } from "../../src/tool/truncate" import { Instance } from "../../src/project/instance" import { WebFetchTool } from "../../src/tool/webfetch" import { SessionID, MessageID } from "../../src/session/schema" @@ -13,62 +17,34 @@ const ctx = { agent: "build", abort: AbortSignal.any([]), messages: [], - metadata: () => {}, - ask: async () => {}, + metadata: () => Effect.void, + ask: () => Effect.void, } -type TimerID = ReturnType - -async function withFetch( - mockFetch: (input: string | URL | Request, init?: RequestInit) => Promise, - fn: () => Promise, -) { - const originalFetch = globalThis.fetch - globalThis.fetch = mockFetch as unknown as typeof fetch - try { - await fn() - } finally { - globalThis.fetch = originalFetch - } +async function withFetch(fetch: (req: Request) => Response | Promise, fn: (url: URL) => Promise) { + using server = Bun.serve({ port: 0, fetch }) + await fn(server.url) } -async function withTimers(fn: (state: { ids: TimerID[]; cleared: TimerID[] }) => Promise) { - const set = globalThis.setTimeout - const clear = globalThis.clearTimeout - const ids: TimerID[] = [] - const cleared: TimerID[] = [] - - globalThis.setTimeout = ((...args: Parameters) => { - const id = set(...args) - ids.push(id) - return id - }) as typeof setTimeout - - globalThis.clearTimeout = ((id?: TimerID) => { - if (id !== undefined) cleared.push(id) - return clear(id) - }) as typeof clearTimeout - - try { - await fn({ ids, cleared }) - } finally { - ids.forEach(clear) - globalThis.setTimeout = set - globalThis.clearTimeout = clear - } +function exec(args: { url: string; format: "text" | "markdown" | "html" }) { + return WebFetchTool.pipe( + Effect.flatMap((info) => info.init()), + Effect.flatMap((tool) => tool.execute(args, ctx)), + Effect.provide(Layer.mergeAll(FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer)), + Effect.runPromise, + ) } describe("tool.webfetch", () => { test("returns image responses as file attachments", async () => { const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]) await withFetch( - async () => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }), - async () => { + () => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }), + async (url) => { await Instance.provide({ directory: projectRoot, fn: async () => { - const webfetch = await WebFetchTool.init() - const result = await webfetch.execute({ url: "https://example.com/image.png", format: "markdown" }, ctx) + const result = await exec({ url: new URL("/image.png", url).toString(), format: "markdown" }) expect(result.output).toBe("Image fetched successfully") expect(result.attachments).toBeDefined() expect(result.attachments?.length).toBe(1) @@ -87,17 +63,16 @@ describe("tool.webfetch", () => { test("keeps svg as text output", async () => { const svg = 'hello' await withFetch( - async () => + () => new Response(svg, { status: 200, headers: { "content-type": "image/svg+xml; charset=UTF-8" }, }), - async () => { + async (url) => { await Instance.provide({ directory: projectRoot, fn: async () => { - const webfetch = await WebFetchTool.init() - const result = await webfetch.execute({ url: "https://example.com/image.svg", format: "html" }, ctx) + const result = await exec({ url: new URL("/image.svg", url).toString(), format: "html" }) expect(result.output).toContain(" { test("keeps text responses as text output", async () => { await withFetch( - async () => + () => new Response("hello from webfetch", { status: 200, headers: { "content-type": "text/plain; charset=utf-8" }, }), - async () => { + async (url) => { await Instance.provide({ directory: projectRoot, fn: async () => { - const webfetch = await WebFetchTool.init() - const result = await webfetch.execute({ url: "https://example.com/file.txt", format: "text" }, ctx) + const result = await exec({ url: new URL("/file.txt", url).toString(), format: "text" }) expect(result.output).toBe("hello from webfetch") expect(result.attachments).toBeUndefined() }, @@ -126,28 +100,4 @@ describe("tool.webfetch", () => { }, ) }) - - test("clears timeout when fetch rejects", async () => { - await withTimers(async ({ ids, cleared }) => { - await withFetch( - async () => { - throw new Error("boom") - }, - async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - const webfetch = await WebFetchTool.init() - await expect( - webfetch.execute({ url: "https://example.com/file.txt", format: "text" }, ctx), - ).rejects.toThrow("boom") - }, - }) - }, - ) - - expect(ids).toHaveLength(1) - expect(cleared).toContain(ids[0]) - }) - }) }) diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 97939c105..f7daa1e97 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -1,10 +1,21 @@ -import { afterEach, describe, test, expect } from "bun:test" +import { afterEach, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" import path from "path" import fs from "fs/promises" import { WriteTool } from "../../src/tool/write" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { LSP } from "../../src/lsp" +import { AppFileSystem } from "../../src/filesystem" +import { FileTime } from "../../src/file/time" +import { Bus } from "../../src/bus" +import { Format } from "../../src/format" +import { Truncate } from "../../src/tool/truncate" +import { Tool } from "../../src/tool/tool" +import { Agent } from "../../src/agent/agent" import { SessionID, MessageID } from "../../src/session/schema" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" const ctx = { sessionID: SessionID.make("ses_test-write-session"), @@ -13,341 +24,233 @@ const ctx = { agent: "build", abort: AbortSignal.any([]), messages: [], - metadata: () => {}, - ask: async () => {}, + metadata: () => Effect.void, + ask: () => Effect.void, } afterEach(async () => { await Instance.disposeAll() }) +const it = testEffect( + Layer.mergeAll( + LSP.defaultLayer, + AppFileSystem.defaultLayer, + FileTime.defaultLayer, + Bus.layer, + Format.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Truncate.defaultLayer, + Agent.defaultLayer, + ), +) + +const init = Effect.fn("WriteToolTest.init")(function* () { + const info = yield* WriteTool + return yield* info.init() +}) + +const run = Effect.fn("WriteToolTest.run")(function* ( + args: Tool.InferParameters, + next: Tool.Context = ctx, +) { + const tool = yield* init() + return yield* tool.execute(args, next) +}) + +const markRead = Effect.fn("WriteToolTest.markRead")(function* (sessionID: string, filepath: string) { + const ft = yield* FileTime.Service + yield* ft.read(sessionID as any, filepath) +}) + describe("tool.write", () => { describe("new file creation", () => { - test("writes content to new file", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "newfile.txt") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const write = await WriteTool.init() - const result = await write.execute( - { - filePath: filepath, - content: "Hello, World!", - }, - ctx, - ) + it.live("writes content to new file", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "newfile.txt") + const result = yield* run({ filePath: filepath, content: "Hello, World!" }) expect(result.output).toContain("Wrote file successfully") expect(result.metadata.exists).toBe(false) - const content = await fs.readFile(filepath, "utf-8") + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) expect(content).toBe("Hello, World!") - }, - }) - }) + }), + ), + ) - test("creates parent directories if needed", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "nested", "deep", "file.txt") + it.live("creates parent directories if needed", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "nested", "deep", "file.txt") + yield* run({ filePath: filepath, content: "nested content" }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const write = await WriteTool.init() - await write.execute( - { - filePath: filepath, - content: "nested content", - }, - ctx, - ) - - const content = await fs.readFile(filepath, "utf-8") + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) expect(content).toBe("nested content") - }, - }) - }) + }), + ), + ) - test("handles relative paths by resolving to instance directory", async () => { - await using tmp = await tmpdir() + it.live("handles relative paths by resolving to instance directory", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* run({ filePath: "relative.txt", content: "relative content" }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const write = await WriteTool.init() - await write.execute( - { - filePath: "relative.txt", - content: "relative content", - }, - ctx, - ) - - const content = await fs.readFile(path.join(tmp.path, "relative.txt"), "utf-8") + const content = yield* Effect.promise(() => fs.readFile(path.join(dir, "relative.txt"), "utf-8")) expect(content).toBe("relative content") - }, - }) - }) + }), + ), + ) }) describe("existing file overwrite", () => { - test("overwrites existing file content", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "existing.txt") - await fs.writeFile(filepath, "old content", "utf-8") + it.live("overwrites existing file content", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "existing.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8")) + yield* markRead(ctx.sessionID, filepath) - // First read the file to satisfy FileTime requirement - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const { FileTime } = await import("../../src/file/time") - await FileTime.read(ctx.sessionID, filepath) - - const write = await WriteTool.init() - const result = await write.execute( - { - filePath: filepath, - content: "new content", - }, - ctx, - ) + const result = yield* run({ filePath: filepath, content: "new content" }) expect(result.output).toContain("Wrote file successfully") expect(result.metadata.exists).toBe(true) - const content = await fs.readFile(filepath, "utf-8") + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) expect(content).toBe("new content") - }, - }) - }) + }), + ), + ) - test("returns diff in metadata for existing files", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "old", "utf-8") + it.live("returns diff in metadata for existing files", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "file.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8")) + yield* markRead(ctx.sessionID, filepath) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const { FileTime } = await import("../../src/file/time") - await FileTime.read(ctx.sessionID, filepath) + const result = yield* run({ filePath: filepath, content: "new" }) - const write = await WriteTool.init() - const result = await write.execute( - { - filePath: filepath, - content: "new", - }, - ctx, - ) - - // Diff should be in metadata expect(result.metadata).toHaveProperty("filepath", filepath) expect(result.metadata).toHaveProperty("exists", true) - }, - }) - }) + }), + ), + ) }) describe("file permissions", () => { - test("sets file permissions when writing sensitive data", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "sensitive.json") + it.live("sets file permissions when writing sensitive data", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "sensitive.json") + yield* run({ filePath: filepath, content: JSON.stringify({ secret: "data" }) }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const write = await WriteTool.init() - await write.execute( - { - filePath: filepath, - content: JSON.stringify({ secret: "data" }), - }, - ctx, - ) - - // On Unix systems, check permissions if (process.platform !== "win32") { - const stats = await fs.stat(filepath) + const stats = yield* Effect.promise(() => fs.stat(filepath)) expect(stats.mode & 0o777).toBe(0o644) } - }, - }) - }) + }), + ), + ) }) describe("content types", () => { - test("writes JSON content", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "data.json") - const data = { key: "value", nested: { array: [1, 2, 3] } } + it.live("writes JSON content", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "data.json") + const data = { key: "value", nested: { array: [1, 2, 3] } } + yield* run({ filePath: filepath, content: JSON.stringify(data, null, 2) }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const write = await WriteTool.init() - await write.execute( - { - filePath: filepath, - content: JSON.stringify(data, null, 2), - }, - ctx, - ) - - const content = await fs.readFile(filepath, "utf-8") + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) expect(JSON.parse(content)).toEqual(data) - }, - }) - }) + }), + ), + ) - test("writes binary-safe content", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "binary.bin") - const content = "Hello\x00World\x01\x02\x03" + it.live("writes binary-safe content", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "binary.bin") + const content = "Hello\x00World\x01\x02\x03" + yield* run({ filePath: filepath, content }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const write = await WriteTool.init() - await write.execute( - { - filePath: filepath, - content, - }, - ctx, - ) - - const buf = await fs.readFile(filepath) + const buf = yield* Effect.promise(() => fs.readFile(filepath)) expect(buf.toString()).toBe(content) - }, - }) - }) + }), + ), + ) - test("writes empty content", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "empty.txt") + it.live("writes empty content", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "empty.txt") + yield* run({ filePath: filepath, content: "" }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const write = await WriteTool.init() - await write.execute( - { - filePath: filepath, - content: "", - }, - ctx, - ) - - const content = await fs.readFile(filepath, "utf-8") + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) expect(content).toBe("") - const stats = await fs.stat(filepath) + const stats = yield* Effect.promise(() => fs.stat(filepath)) expect(stats.size).toBe(0) - }, - }) - }) + }), + ), + ) - test("writes multi-line content", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "multiline.txt") - const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n") + it.live("writes multi-line content", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "multiline.txt") + const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n") + yield* run({ filePath: filepath, content: lines }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const write = await WriteTool.init() - await write.execute( - { - filePath: filepath, - content: lines, - }, - ctx, - ) - - const content = await fs.readFile(filepath, "utf-8") + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) expect(content).toBe(lines) - }, - }) - }) + }), + ), + ) - test("handles different line endings", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "crlf.txt") - const content = "Line 1\r\nLine 2\r\nLine 3" + it.live("handles different line endings", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "crlf.txt") + const content = "Line 1\r\nLine 2\r\nLine 3" + yield* run({ filePath: filepath, content }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const write = await WriteTool.init() - await write.execute( - { - filePath: filepath, - content, - }, - ctx, - ) - - const buf = await fs.readFile(filepath) + const buf = yield* Effect.promise(() => fs.readFile(filepath)) expect(buf.toString()).toBe(content) - }, - }) - }) + }), + ), + ) }) describe("error handling", () => { - test("throws error when OS denies write access", async () => { - await using tmp = await tmpdir() - const readonlyPath = path.join(tmp.path, "readonly.txt") + it.live("throws error when OS denies write access", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const readonlyPath = path.join(dir, "readonly.txt") + yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8")) + yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444)) + yield* markRead(ctx.sessionID, readonlyPath) - // Create a read-only file - await fs.writeFile(readonlyPath, "test", "utf-8") - await fs.chmod(readonlyPath, 0o444) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const { FileTime } = await import("../../src/file/time") - await FileTime.read(ctx.sessionID, readonlyPath) - - const write = await WriteTool.init() - await expect( - write.execute( - { - filePath: readonlyPath, - content: "new content", - }, - ctx, - ), - ).rejects.toThrow() - }, - }) - }) + const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit) + expect(exit._tag).toBe("Failure") + }), + ), + ) }) describe("title generation", () => { - test("returns relative path as title", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "src", "components", "Button.tsx") - await fs.mkdir(path.dirname(filepath), { recursive: true }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const write = await WriteTool.init() - const result = await write.execute( - { - filePath: filepath, - content: "export const Button = () => {}", - }, - ctx, - ) + it.live("returns relative path as title", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "src", "components", "Button.tsx") + yield* Effect.promise(() => fs.mkdir(path.dirname(filepath), { recursive: true })) + const result = yield* run({ filePath: filepath, content: "export const Button = () => {}" }) expect(result.title).toEndWith(path.join("src", "components", "Button.tsx")) - }, - }) - }) + }), + ), + ) }) }) diff --git a/packages/opencode/test/util/log.test.ts b/packages/opencode/test/util/log.test.ts new file mode 100644 index 000000000..33e64fcd0 --- /dev/null +++ b/packages/opencode/test/util/log.test.ts @@ -0,0 +1,44 @@ +import { afterEach, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Global } from "../../src/global" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +const log = Global.Path.log + +afterEach(() => { + Global.Path.log = log +}) + +async function files(dir: string) { + let last = "" + let same = 0 + + for (let i = 0; i < 50; i++) { + const list = (await fs.readdir(dir)).sort() + const next = JSON.stringify(list) + same = next === last ? same + 1 : 0 + if (same >= 2 && list.length === 11) return list + last = next + await Bun.sleep(10) + } + + return (await fs.readdir(dir)).sort() +} + +test("init cleanup keeps the newest timestamped logs", async () => { + await using tmp = await tmpdir() + Global.Path.log = tmp.path + + const list = Array.from({ length: 12 }, (_, i) => `2000-01-${String(i + 1).padStart(2, "0")}T000000.log`) + + await Promise.all(list.map((file) => fs.writeFile(path.join(tmp.path, file), file))) + + await Log.init({ print: false, dev: false }) + + const next = await files(tmp.path) + + expect(next).not.toContain(list[0]!) + expect(next).toContain(list.at(-1)!) +}) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 6e3fc8348..ba4735371 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.4.0", + "version": "1.4.3", "type": "module", "license": "MIT", "scripts": { @@ -18,6 +18,7 @@ ], "dependencies": { "@opencode-ai/sdk": "workspace:*", + "effect": "catalog:", "zod": "catalog:" }, "peerDependencies": { diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts index 23aa512d9..b568d0371 100644 --- a/packages/plugin/src/tool.ts +++ b/packages/plugin/src/tool.ts @@ -1,4 +1,5 @@ import { z } from "zod" +import { Effect } from "effect" export type ToolContext = { sessionID: string @@ -16,7 +17,7 @@ export type ToolContext = { worktree: string abort: AbortSignal metadata(input: { title?: string; metadata?: { [key: string]: any } }): void - ask(input: AskInput): Promise + ask(input: AskInput): Effect.Effect } type AskInput = { diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 8f8439fab..e6f832f7e 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -272,10 +272,6 @@ export type TuiState = { directory: string } readonly vcs: { branch?: string } | undefined - readonly workspace: { - list: () => ReadonlyArray - get: (workspaceID: string) => Workspace | undefined - } session: { count: () => number diff: (sessionID: string) => ReadonlyArray diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index c33b561d1..47e09dfab 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.4.0", + "version": "1.4.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index b2e37db59..7fba60fce 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -34,6 +34,7 @@ import type { ExperimentalWorkspaceListResponses, ExperimentalWorkspaceRemoveErrors, ExperimentalWorkspaceRemoveResponses, + ExperimentalWorkspaceStatusResponses, FileListResponses, FilePartInput, FilePartSource, @@ -1163,6 +1164,36 @@ export class Workspace extends HeyApiClient { }) } + /** + * Workspace status + * + * Get connection status for workspaces in the current project. + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/workspace/status", + ...options, + ...params, + }) + } + /** * Remove workspace * @@ -1694,6 +1725,7 @@ export class Session2 extends HeyApiClient { directory?: string workspace?: string title?: string + permission?: PermissionRuleset time?: { archived?: number } @@ -1709,6 +1741,7 @@ export class Session2 extends HeyApiClient { { in: "query", key: "directory" }, { in: "query", key: "workspace" }, { in: "body", key: "title" }, + { in: "body", key: "permission" }, { in: "body", key: "time" }, ], }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 0a9aa4358..4aab9f439 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -123,230 +123,6 @@ export type EventPermissionReplied = { } } -export type SessionStatus = - | { - type: "idle" - } - | { - type: "retry" - attempt: number - message: string - next: number - } - | { - type: "busy" - } - -export type EventSessionStatus = { - type: "session.status" - properties: { - sessionID: string - status: SessionStatus - } -} - -export type EventSessionIdle = { - type: "session.idle" - properties: { - sessionID: string - } -} - -export type QuestionOption = { - /** - * Display text (1-5 words, concise) - */ - label: string - /** - * Explanation of choice - */ - description: string -} - -export type QuestionInfo = { - /** - * Complete question - */ - question: string - /** - * Very short label (max 30 chars) - */ - header: string - /** - * Available choices - */ - options: Array - /** - * Allow selecting multiple choices - */ - multiple?: boolean - /** - * Allow typing a custom answer (default: true) - */ - custom?: boolean -} - -export type QuestionRequest = { - id: string - sessionID: string - /** - * Questions to ask - */ - questions: Array - tool?: { - messageID: string - callID: string - } -} - -export type EventQuestionAsked = { - type: "question.asked" - properties: QuestionRequest -} - -export type QuestionAnswer = Array - -export type EventQuestionReplied = { - type: "question.replied" - properties: { - sessionID: string - requestID: string - answers: Array - } -} - -export type EventQuestionRejected = { - type: "question.rejected" - properties: { - sessionID: string - requestID: string - } -} - -export type EventSessionCompacted = { - type: "session.compacted" - properties: { - sessionID: string - } -} - -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - -export type Todo = { - /** - * Brief description of the task - */ - content: string - /** - * Current status of the task: pending, in_progress, completed, cancelled - */ - status: string - /** - * Priority level of the task: high, medium, low - */ - priority: string -} - -export type EventTodoUpdated = { - type: "todo.updated" - properties: { - sessionID: string - todos: Array - } -} - -export type EventTuiPromptAppend = { - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - /** - * Duration in milliseconds - */ - duration?: number - } -} - -export type EventTuiSessionSelect = { - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - -export type EventMcpToolsChanged = { - type: "mcp.tools.changed" - properties: { - server: string - } -} - -export type EventMcpBrowserOpenFailed = { - type: "mcp.browser.open.failed" - properties: { - mcpName: string - url: string - } -} - -export type EventCommandExecuted = { - type: "command.executed" - properties: { - name: string - sessionID: string - arguments: string - messageID: string - } -} - export type SnapshotFileDiff = { file: string patch: string @@ -439,6 +215,21 @@ export type EventSessionError = { } } +export type EventFileEdited = { + type: "file.edited" + properties: { + file: string + } +} + +export type EventFileWatcherUpdated = { + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + export type EventVcsBranchUpdated = { type: "vcs.branch.updated" properties: { @@ -446,15 +237,225 @@ export type EventVcsBranchUpdated = { } } -export type EventWorkspaceReady = { - type: "workspace.ready" +export type EventTuiPromptAppend = { + type: "tui.prompt.append" properties: { - name: string + text: string } } -export type EventWorkspaceFailed = { - type: "workspace.failed" +export type EventTuiCommandExecute = { + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + /** + * Duration in milliseconds + */ + duration?: number + } +} + +export type EventTuiSessionSelect = { + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + +export type EventMcpToolsChanged = { + type: "mcp.tools.changed" + properties: { + server: string + } +} + +export type EventMcpBrowserOpenFailed = { + type: "mcp.browser.open.failed" + properties: { + mcpName: string + url: string + } +} + +export type EventCommandExecuted = { + type: "command.executed" + properties: { + name: string + sessionID: string + arguments: string + messageID: string + } +} + +export type QuestionOption = { + /** + * Display text (1-5 words, concise) + */ + label: string + /** + * Explanation of choice + */ + description: string +} + +export type QuestionInfo = { + /** + * Complete question + */ + question: string + /** + * Very short label (max 30 chars) + */ + header: string + /** + * Available choices + */ + options: Array + /** + * Allow selecting multiple choices + */ + multiple?: boolean + /** + * Allow typing a custom answer (default: true) + */ + custom?: boolean +} + +export type QuestionRequest = { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: { + messageID: string + callID: string + } +} + +export type EventQuestionAsked = { + type: "question.asked" + properties: QuestionRequest +} + +export type QuestionAnswer = Array + +export type EventQuestionReplied = { + type: "question.replied" + properties: { + sessionID: string + requestID: string + answers: Array + } +} + +export type EventQuestionRejected = { + type: "question.rejected" + properties: { + sessionID: string + requestID: string + } +} + +export type Todo = { + /** + * Brief description of the task + */ + content: string + /** + * Current status of the task: pending, in_progress, completed, cancelled + */ + status: string + /** + * Priority level of the task: high, medium, low + */ + priority: string +} + +export type EventTodoUpdated = { + type: "todo.updated" + properties: { + sessionID: string + todos: Array + } +} + +export type SessionStatus = + | { + type: "idle" + } + | { + type: "retry" + attempt: number + message: string + next: number + } + | { + type: "busy" + } + +export type EventSessionStatus = { + type: "session.status" + properties: { + sessionID: string + status: SessionStatus + } +} + +export type EventSessionIdle = { + type: "session.idle" + properties: { + sessionID: string + } +} + +export type EventSessionCompacted = { + type: "session.compacted" + properties: { + sessionID: string + } +} + +export type EventWorktreeReady = { + type: "worktree.ready" + properties: { + name: string + branch: string + } +} + +export type EventWorktreeFailed = { + type: "worktree.failed" properties: { message: string } @@ -499,21 +500,29 @@ export type EventPtyDeleted = { } } -export type EventWorktreeReady = { - type: "worktree.ready" +export type EventWorkspaceReady = { + type: "workspace.ready" properties: { name: string - branch: string } } -export type EventWorktreeFailed = { - type: "worktree.failed" +export type EventWorkspaceFailed = { + type: "workspace.failed" properties: { message: string } } +export type EventWorkspaceStatus = { + type: "workspace.status" + properties: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + error?: string + } +} + export type OutputFormatText = { type: "text" } @@ -974,15 +983,11 @@ export type Event = | EventMessagePartDelta | EventPermissionAsked | EventPermissionReplied - | EventSessionStatus - | EventSessionIdle - | EventQuestionAsked - | EventQuestionReplied - | EventQuestionRejected - | EventSessionCompacted + | EventSessionDiff + | EventSessionError | EventFileEdited | EventFileWatcherUpdated - | EventTodoUpdated + | EventVcsBranchUpdated | EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow @@ -990,17 +995,22 @@ export type Event = | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted - | EventSessionDiff - | EventSessionError - | EventVcsBranchUpdated - | EventWorkspaceReady - | EventWorkspaceFailed + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected + | EventTodoUpdated + | EventSessionStatus + | EventSessionIdle + | EventSessionCompacted + | EventWorktreeReady + | EventWorktreeFailed | EventPtyCreated | EventPtyUpdated | EventPtyExited | EventPtyDeleted - | EventWorktreeReady - | EventWorktreeFailed + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceStatus | EventMessageUpdated | EventMessageRemoved | EventMessagePartUpdated @@ -1011,6 +1021,8 @@ export type Event = export type GlobalEvent = { directory: string + project?: string + workspace?: string payload: Event } @@ -1250,6 +1262,29 @@ export type ProviderConfig = { env?: Array id?: string npm?: string + whitelist?: Array + blacklist?: Array + options?: { + apiKey?: string + baseURL?: string + /** + * GitHub Enterprise URL for copilot authentication + */ + enterpriseUrl?: string + /** + * Enable promptCacheKey for this provider (default false) + */ + setCacheKey?: boolean + /** + * Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout. + */ + timeout?: number | false + /** + * Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted. + */ + chunkTimeout?: number + [key: string]: unknown | string | boolean | number | false | number | undefined + } models?: { [key: string]: { id?: string @@ -1288,16 +1323,16 @@ export type ProviderConfig = { } experimental?: boolean status?: "alpha" | "beta" | "deprecated" + provider?: { + npm?: string + api?: string + } options?: { [key: string]: unknown } headers?: { [key: string]: string } - provider?: { - npm?: string - api?: string - } /** * Variant-specific configuration */ @@ -1312,29 +1347,6 @@ export type ProviderConfig = { } } } - whitelist?: Array - blacklist?: Array - options?: { - apiKey?: string - baseURL?: string - /** - * GitHub Enterprise URL for copilot authentication - */ - enterpriseUrl?: string - /** - * Enable promptCacheKey for this provider (default false) - */ - setCacheKey?: boolean - /** - * Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout. - */ - timeout?: number | false - /** - * Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted. - */ - chunkTimeout?: number - [key: string]: unknown | string | boolean | number | false | number | undefined - } } export type McpLocalConfig = { @@ -1375,6 +1387,10 @@ export type McpOAuthConfig = { * OAuth scopes to request during authorization */ scope?: string + /** + * OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback). + */ + redirectUri?: string } export type McpRemoteConfig = { @@ -2851,6 +2867,30 @@ export type ExperimentalWorkspaceCreateResponses = { export type ExperimentalWorkspaceCreateResponse = ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] +export type ExperimentalWorkspaceStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/status" +} + +export type ExperimentalWorkspaceStatusResponses = { + /** + * Workspace status + */ + 200: Array<{ + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + error?: string + }> +} + +export type ExperimentalWorkspaceStatusResponse = + ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses] + export type ExperimentalWorkspaceRemoveData = { body?: never path: { @@ -3226,6 +3266,7 @@ export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses] export type SessionUpdateData = { body?: { title?: string + permission?: PermissionRuleset time?: { archived?: number } @@ -3936,7 +3977,10 @@ export type SessionShellResponses = { /** * Created message */ - 200: AssistantMessage + 200: { + info: Message + parts: Array + } } export type SessionShellResponse = SessionShellResponses[keyof SessionShellResponses] @@ -4212,68 +4256,7 @@ export type ProviderListResponses = { * List of providers */ 200: { - all: Array<{ - api?: string - name: string - env: Array - id: string - npm?: string - models: { - [key: string]: { - id: string - name: string - family?: string - release_date: string - attachment: boolean - reasoning: boolean - temperature: boolean - tool_call: boolean - interleaved?: - | true - | { - field: "reasoning_content" | "reasoning_details" - } - cost?: { - input: number - output: number - cache_read?: number - cache_write?: number - context_over_200k?: { - input: number - output: number - cache_read?: number - cache_write?: number - } - } - limit: { - context: number - input?: number - output: number - } - modalities?: { - input: Array<"text" | "audio" | "image" | "video" | "pdf"> - output: Array<"text" | "audio" | "image" | "video" | "pdf"> - } - experimental?: boolean - status?: "alpha" | "beta" | "deprecated" - options: { - [key: string]: unknown - } - headers?: { - [key: string]: string - } - provider?: { - npm?: string - api?: string - } - variants?: { - [key: string]: { - [key: string]: unknown - } - } - } - } - }> + all: Array default: { [key: string]: string } diff --git a/packages/sdk/js/src/v2/index.ts b/packages/sdk/js/src/v2/index.ts index d514784bc..9615eacc7 100644 --- a/packages/sdk/js/src/v2/index.ts +++ b/packages/sdk/js/src/v2/index.ts @@ -6,7 +6,6 @@ import { createOpencodeServer } from "./server.js" import type { ServerOptions } from "./server.js" export * as data from "./data.js" -import * as data from "./data.js" export async function createOpencode(options?: ServerOptions) { const server = await createOpencodeServer({ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 207b400a7..e8d6e6a08 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1656,6 +1656,64 @@ ] } }, + "/experimental/workspace/status": { + "get": { + "operationId": "experimental.workspace.status", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "Workspace status", + "description": "Get connection status for workspaces in the current project.", + "responses": { + "200": { + "description": "Workspace status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + }, + "error": { + "type": "string" + } + }, + "required": ["workspaceID", "status"] + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.status({\n ...\n})" + } + ] + } + }, "/experimental/workspace/{id}": { "delete": { "operationId": "experimental.workspace.remove", @@ -2492,6 +2550,9 @@ "title": { "type": "string" }, + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + }, "time": { "type": "object", "properties": { @@ -4098,7 +4159,19 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssistantMessage" + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Message" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + }, + "required": ["info", "parts"] } } } @@ -4790,211 +4863,7 @@ "all": { "type": "array", "items": { - "type": "object", - "properties": { - "api": { - "type": "string" - }, - "name": { - "type": "string" - }, - "env": { - "type": "array", - "items": { - "type": "string" - } - }, - "id": { - "type": "string" - }, - "npm": { - "type": "string" - }, - "models": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "family": { - "type": "string" - }, - "release_date": { - "type": "string" - }, - "attachment": { - "type": "boolean" - }, - "reasoning": { - "type": "boolean" - }, - "temperature": { - "type": "boolean" - }, - "tool_call": { - "type": "boolean" - }, - "interleaved": { - "anyOf": [ - { - "type": "boolean", - "const": true - }, - { - "type": "object", - "properties": { - "field": { - "type": "string", - "enum": ["reasoning_content", "reasoning_details"] - } - }, - "required": ["field"], - "additionalProperties": false - } - ] - }, - "cost": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "cache_read": { - "type": "number" - }, - "cache_write": { - "type": "number" - }, - "context_over_200k": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "cache_read": { - "type": "number" - }, - "cache_write": { - "type": "number" - } - }, - "required": ["input", "output"] - } - }, - "required": ["input", "output"] - }, - "limit": { - "type": "object", - "properties": { - "context": { - "type": "number" - }, - "input": { - "type": "number" - }, - "output": { - "type": "number" - } - }, - "required": ["context", "output"] - }, - "modalities": { - "type": "object", - "properties": { - "input": { - "type": "array", - "items": { - "type": "string", - "enum": ["text", "audio", "image", "video", "pdf"] - } - }, - "output": { - "type": "array", - "items": { - "type": "string", - "enum": ["text", "audio", "image", "video", "pdf"] - } - } - }, - "required": ["input", "output"] - }, - "experimental": { - "type": "boolean" - }, - "status": { - "type": "string", - "enum": ["alpha", "beta", "deprecated"] - }, - "options": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "headers": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - }, - "provider": { - "type": "object", - "properties": { - "npm": { - "type": "string" - }, - "api": { - "type": "string" - } - } - }, - "variants": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - } - }, - "required": [ - "id", - "name", - "release_date", - "attachment", - "reasoning", - "temperature", - "tool_call", - "limit", - "options" - ] - } - } - }, - "required": ["name", "env", "id", "models"] + "$ref": "#/components/schemas/Provider" } }, "default": { @@ -7606,546 +7475,6 @@ }, "required": ["type", "properties"] }, - "SessionStatus": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "idle" - } - }, - "required": ["type"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "retry" - }, - "attempt": { - "type": "number" - }, - "message": { - "type": "string" - }, - "next": { - "type": "number" - } - }, - "required": ["type", "attempt", "message", "next"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "busy" - } - }, - "required": ["type"] - } - ] - }, - "Event.session.status": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.status" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "status": { - "$ref": "#/components/schemas/SessionStatus" - } - }, - "required": ["sessionID", "status"] - } - }, - "required": ["type", "properties"] - }, - "Event.session.idle": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.idle" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] - } - }, - "required": ["type", "properties"] - }, - "QuestionOption": { - "type": "object", - "properties": { - "label": { - "description": "Display text (1-5 words, concise)", - "type": "string" - }, - "description": { - "description": "Explanation of choice", - "type": "string" - } - }, - "required": ["label", "description"] - }, - "QuestionInfo": { - "type": "object", - "properties": { - "question": { - "description": "Complete question", - "type": "string" - }, - "header": { - "description": "Very short label (max 30 chars)", - "type": "string" - }, - "options": { - "description": "Available choices", - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionOption" - } - }, - "multiple": { - "description": "Allow selecting multiple choices", - "type": "boolean" - }, - "custom": { - "description": "Allow typing a custom answer (default: true)", - "type": "boolean" - } - }, - "required": ["question", "header", "options"] - }, - "QuestionRequest": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^que.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "questions": { - "description": "Questions to ask", - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionInfo" - } - }, - "tool": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "callID": { - "type": "string" - } - }, - "required": ["messageID", "callID"] - } - }, - "required": ["id", "sessionID", "questions"] - }, - "Event.question.asked": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "question.asked" - }, - "properties": { - "$ref": "#/components/schemas/QuestionRequest" - } - }, - "required": ["type", "properties"] - }, - "QuestionAnswer": { - "type": "array", - "items": { - "type": "string" - } - }, - "Event.question.replied": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "question.replied" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^que.*" - }, - "answers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionAnswer" - } - } - }, - "required": ["sessionID", "requestID", "answers"] - } - }, - "required": ["type", "properties"] - }, - "Event.question.rejected": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "question.rejected" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^que.*" - } - }, - "required": ["sessionID", "requestID"] - } - }, - "required": ["type", "properties"] - }, - "Event.session.compacted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.compacted" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] - } - }, - "required": ["type", "properties"] - }, - "Event.file.edited": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.edited" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - } - }, - "required": ["file"] - } - }, - "required": ["type", "properties"] - }, - "Event.file.watcher.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.watcher.updated" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "anyOf": [ - { - "type": "string", - "const": "add" - }, - { - "type": "string", - "const": "change" - }, - { - "type": "string", - "const": "unlink" - } - ] - } - }, - "required": ["file", "event"] - } - }, - "required": ["type", "properties"] - }, - "Todo": { - "type": "object", - "properties": { - "content": { - "description": "Brief description of the task", - "type": "string" - }, - "status": { - "description": "Current status of the task: pending, in_progress, completed, cancelled", - "type": "string" - }, - "priority": { - "description": "Priority level of the task: high, medium, low", - "type": "string" - } - }, - "required": ["content", "status", "priority"] - }, - "Event.todo.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "todo.updated" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "todos": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Todo" - } - } - }, - "required": ["sessionID", "todos"] - } - }, - "required": ["type", "properties"] - }, - "Event.tui.prompt.append": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tui.prompt.append" - }, - "properties": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - }, - "required": ["type", "properties"] - }, - "Event.tui.command.execute": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tui.command.execute" - }, - "properties": { - "type": "object", - "properties": { - "command": { - "anyOf": [ - { - "type": "string", - "enum": [ - "session.list", - "session.new", - "session.share", - "session.interrupt", - "session.compact", - "session.page.up", - "session.page.down", - "session.line.up", - "session.line.down", - "session.half.page.up", - "session.half.page.down", - "session.first", - "session.last", - "prompt.clear", - "prompt.submit", - "agent.cycle" - ] - }, - { - "type": "string" - } - ] - } - }, - "required": ["command"] - } - }, - "required": ["type", "properties"] - }, - "Event.tui.toast.show": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tui.toast.show" - }, - "properties": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "message": { - "type": "string" - }, - "variant": { - "type": "string", - "enum": ["info", "success", "warning", "error"] - }, - "duration": { - "description": "Duration in milliseconds", - "default": 5000, - "type": "number" - } - }, - "required": ["message", "variant"] - } - }, - "required": ["type", "properties"] - }, - "Event.tui.session.select": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tui.session.select" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "description": "Session ID to navigate to", - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] - } - }, - "required": ["type", "properties"] - }, - "Event.mcp.tools.changed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "mcp.tools.changed" - }, - "properties": { - "type": "object", - "properties": { - "server": { - "type": "string" - } - }, - "required": ["server"] - } - }, - "required": ["type", "properties"] - }, - "Event.mcp.browser.open.failed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "mcp.browser.open.failed" - }, - "properties": { - "type": "object", - "properties": { - "mcpName": { - "type": "string" - }, - "url": { - "type": "string" - } - }, - "required": ["mcpName", "url"] - } - }, - "required": ["type", "properties"] - }, - "Event.command.executed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "command.executed" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "arguments": { - "type": "string" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - } - }, - "required": ["name", "sessionID", "arguments", "messageID"] - } - }, - "required": ["type", "properties"] - }, "SnapshotFileDiff": { "type": "object", "properties": { @@ -8402,6 +7731,60 @@ }, "required": ["type", "properties"] }, + "Event.file.edited": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.edited" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"] + } + }, + "required": ["type", "properties"] + }, + "Event.file.watcher.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.watcher.updated" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "anyOf": [ + { + "type": "string", + "const": "add" + }, + { + "type": "string", + "const": "change" + }, + { + "type": "string", + "const": "unlink" + } + ] + } + }, + "required": ["file", "event"] + } + }, + "required": ["type", "properties"] + }, "Event.vcs.branch.updated": { "type": "object", "properties": { @@ -8420,31 +7803,520 @@ }, "required": ["type", "properties"] }, - "Event.workspace.ready": { + "Event.tui.prompt.append": { "type": "object", "properties": { "type": { "type": "string", - "const": "workspace.ready" + "const": "tui.prompt.append" + }, + "properties": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"] + } + }, + "required": ["type", "properties"] + }, + "Event.tui.command.execute": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tui.command.execute" + }, + "properties": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle" + ] + }, + { + "type": "string" + } + ] + } + }, + "required": ["command"] + } + }, + "required": ["type", "properties"] + }, + "Event.tui.toast.show": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tui.toast.show" + }, + "properties": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "description": "Duration in milliseconds", + "default": 5000, + "type": "number" + } + }, + "required": ["message", "variant"] + } + }, + "required": ["type", "properties"] + }, + "Event.tui.session.select": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tui.session.select" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "description": "Session ID to navigate to", + "type": "string", + "pattern": "^ses.*" + } + }, + "required": ["sessionID"] + } + }, + "required": ["type", "properties"] + }, + "Event.mcp.tools.changed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "mcp.tools.changed" + }, + "properties": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": ["server"] + } + }, + "required": ["type", "properties"] + }, + "Event.mcp.browser.open.failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "mcp.browser.open.failed" + }, + "properties": { + "type": "object", + "properties": { + "mcpName": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["mcpName", "url"] + } + }, + "required": ["type", "properties"] + }, + "Event.command.executed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "command.executed" }, "properties": { "type": "object", "properties": { "name": { "type": "string" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "arguments": { + "type": "string" + }, + "messageID": { + "type": "string", + "pattern": "^msg.*" } }, - "required": ["name"] + "required": ["name", "sessionID", "arguments", "messageID"] } }, "required": ["type", "properties"] }, - "Event.workspace.failed": { + "QuestionOption": { + "type": "object", + "properties": { + "label": { + "description": "Display text (1-5 words, concise)", + "type": "string" + }, + "description": { + "description": "Explanation of choice", + "type": "string" + } + }, + "required": ["label", "description"] + }, + "QuestionInfo": { + "type": "object", + "properties": { + "question": { + "description": "Complete question", + "type": "string" + }, + "header": { + "description": "Very short label (max 30 chars)", + "type": "string" + }, + "options": { + "description": "Available choices", + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionOption" + } + }, + "multiple": { + "description": "Allow selecting multiple choices", + "type": "boolean" + }, + "custom": { + "description": "Allow typing a custom answer (default: true)", + "type": "boolean" + } + }, + "required": ["question", "header", "options"] + }, + "QuestionRequest": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^que.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "questions": { + "description": "Questions to ask", + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionInfo" + } + }, + "tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"] + } + }, + "required": ["id", "sessionID", "questions"] + }, + "Event.question.asked": { "type": "object", "properties": { "type": { "type": "string", - "const": "workspace.failed" + "const": "question.asked" + }, + "properties": { + "$ref": "#/components/schemas/QuestionRequest" + } + }, + "required": ["type", "properties"] + }, + "QuestionAnswer": { + "type": "array", + "items": { + "type": "string" + } + }, + "Event.question.replied": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "question.replied" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "requestID": { + "type": "string", + "pattern": "^que.*" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } + } + }, + "required": ["sessionID", "requestID", "answers"] + } + }, + "required": ["type", "properties"] + }, + "Event.question.rejected": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "question.rejected" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "requestID": { + "type": "string", + "pattern": "^que.*" + } + }, + "required": ["sessionID", "requestID"] + } + }, + "required": ["type", "properties"] + }, + "Todo": { + "type": "object", + "properties": { + "content": { + "description": "Brief description of the task", + "type": "string" + }, + "status": { + "description": "Current status of the task: pending, in_progress, completed, cancelled", + "type": "string" + }, + "priority": { + "description": "Priority level of the task: high, medium, low", + "type": "string" + } + }, + "required": ["content", "status", "priority"] + }, + "Event.todo.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "todo.updated" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": ["sessionID", "todos"] + } + }, + "required": ["type", "properties"] + }, + "SessionStatus": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "idle" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "retry" + }, + "attempt": { + "type": "number" + }, + "message": { + "type": "string" + }, + "next": { + "type": "number" + } + }, + "required": ["type", "attempt", "message", "next"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "busy" + } + }, + "required": ["type"] + } + ] + }, + "Event.session.status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.status" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "status": { + "$ref": "#/components/schemas/SessionStatus" + } + }, + "required": ["sessionID", "status"] + } + }, + "required": ["type", "properties"] + }, + "Event.session.idle": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.idle" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + } + }, + "required": ["sessionID"] + } + }, + "required": ["type", "properties"] + }, + "Event.session.compacted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.compacted" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + } + }, + "required": ["sessionID"] + } + }, + "required": ["type", "properties"] + }, + "Event.worktree.ready": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "worktree.ready" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "branch": { + "type": "string" + } + }, + "required": ["name", "branch"] + } + }, + "required": ["type", "properties"] + }, + "Event.worktree.failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "worktree.failed" }, "properties": { "type": "object", @@ -8571,34 +8443,31 @@ }, "required": ["type", "properties"] }, - "Event.worktree.ready": { + "Event.workspace.ready": { "type": "object", "properties": { "type": { "type": "string", - "const": "worktree.ready" + "const": "workspace.ready" }, "properties": { "type": "object", "properties": { "name": { "type": "string" - }, - "branch": { - "type": "string" } }, - "required": ["name", "branch"] + "required": ["name"] } }, "required": ["type", "properties"] }, - "Event.worktree.failed": { + "Event.workspace.failed": { "type": "object", "properties": { "type": { "type": "string", - "const": "worktree.failed" + "const": "workspace.failed" }, "properties": { "type": "object", @@ -8612,6 +8481,33 @@ }, "required": ["type", "properties"] }, + "Event.workspace.status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.status" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + }, + "error": { + "type": "string" + } + }, + "required": ["workspaceID", "status"] + } + }, + "required": ["type", "properties"] + }, "OutputFormatText": { "type": "object", "properties": { @@ -10009,22 +9905,10 @@ "$ref": "#/components/schemas/Event.permission.replied" }, { - "$ref": "#/components/schemas/Event.session.status" + "$ref": "#/components/schemas/Event.session.diff" }, { - "$ref": "#/components/schemas/Event.session.idle" - }, - { - "$ref": "#/components/schemas/Event.question.asked" - }, - { - "$ref": "#/components/schemas/Event.question.replied" - }, - { - "$ref": "#/components/schemas/Event.question.rejected" - }, - { - "$ref": "#/components/schemas/Event.session.compacted" + "$ref": "#/components/schemas/Event.session.error" }, { "$ref": "#/components/schemas/Event.file.edited" @@ -10033,7 +9917,7 @@ "$ref": "#/components/schemas/Event.file.watcher.updated" }, { - "$ref": "#/components/schemas/Event.todo.updated" + "$ref": "#/components/schemas/Event.vcs.branch.updated" }, { "$ref": "#/components/schemas/Event.tui.prompt.append" @@ -10057,19 +9941,31 @@ "$ref": "#/components/schemas/Event.command.executed" }, { - "$ref": "#/components/schemas/Event.session.diff" + "$ref": "#/components/schemas/Event.question.asked" }, { - "$ref": "#/components/schemas/Event.session.error" + "$ref": "#/components/schemas/Event.question.replied" }, { - "$ref": "#/components/schemas/Event.vcs.branch.updated" + "$ref": "#/components/schemas/Event.question.rejected" }, { - "$ref": "#/components/schemas/Event.workspace.ready" + "$ref": "#/components/schemas/Event.todo.updated" }, { - "$ref": "#/components/schemas/Event.workspace.failed" + "$ref": "#/components/schemas/Event.session.status" + }, + { + "$ref": "#/components/schemas/Event.session.idle" + }, + { + "$ref": "#/components/schemas/Event.session.compacted" + }, + { + "$ref": "#/components/schemas/Event.worktree.ready" + }, + { + "$ref": "#/components/schemas/Event.worktree.failed" }, { "$ref": "#/components/schemas/Event.pty.created" @@ -10084,10 +9980,13 @@ "$ref": "#/components/schemas/Event.pty.deleted" }, { - "$ref": "#/components/schemas/Event.worktree.ready" + "$ref": "#/components/schemas/Event.workspace.ready" }, { - "$ref": "#/components/schemas/Event.worktree.failed" + "$ref": "#/components/schemas/Event.workspace.failed" + }, + { + "$ref": "#/components/schemas/Event.workspace.status" }, { "$ref": "#/components/schemas/Event.message.updated" @@ -10118,6 +10017,12 @@ "directory": { "type": "string" }, + "project": { + "type": "string" + }, + "workspace": { + "type": "string" + }, "payload": { "$ref": "#/components/schemas/Event" } @@ -10788,6 +10693,60 @@ "npm": { "type": "string" }, + "whitelist": { + "type": "array", + "items": { + "type": "string" + } + }, + "blacklist": { + "type": "array", + "items": { + "type": "string" + } + }, + "options": { + "type": "object", + "properties": { + "apiKey": { + "type": "string" + }, + "baseURL": { + "type": "string" + }, + "enterpriseUrl": { + "description": "GitHub Enterprise URL for copilot authentication", + "type": "string" + }, + "setCacheKey": { + "description": "Enable promptCacheKey for this provider (default false)", + "type": "boolean" + }, + "timeout": { + "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", + "anyOf": [ + { + "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + { + "description": "Disable timeout for this provider entirely.", + "type": "boolean", + "const": false + } + ] + }, + "chunkTimeout": { + "description": "Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "additionalProperties": {} + }, "models": { "type": "object", "propertyNames": { @@ -10917,6 +10876,17 @@ "type": "string", "enum": ["alpha", "beta", "deprecated"] }, + "provider": { + "type": "object", + "properties": { + "npm": { + "type": "string" + }, + "api": { + "type": "string" + } + } + }, "options": { "type": "object", "propertyNames": { @@ -10933,17 +10903,6 @@ "type": "string" } }, - "provider": { - "type": "object", - "properties": { - "npm": { - "type": "string" - }, - "api": { - "type": "string" - } - } - }, "variants": { "description": "Variant-specific configuration", "type": "object", @@ -10963,60 +10922,6 @@ } } } - }, - "whitelist": { - "type": "array", - "items": { - "type": "string" - } - }, - "blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "options": { - "type": "object", - "properties": { - "apiKey": { - "type": "string" - }, - "baseURL": { - "type": "string" - }, - "enterpriseUrl": { - "description": "GitHub Enterprise URL for copilot authentication", - "type": "string" - }, - "setCacheKey": { - "description": "Enable promptCacheKey for this provider (default false)", - "type": "boolean" - }, - "timeout": { - "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", - "anyOf": [ - { - "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - }, - { - "description": "Disable timeout for this provider entirely.", - "type": "boolean", - "const": false - } - ] - }, - "chunkTimeout": { - "description": "Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - } - }, - "additionalProperties": {} } }, "additionalProperties": false @@ -11074,6 +10979,10 @@ "scope": { "description": "OAuth scopes to request during authorization", "type": "string" + }, + "redirectUri": { + "description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", + "type": "string" } }, "additionalProperties": false diff --git a/packages/slack/package.json b/packages/slack/package.json index 0e7752527..892c7172e 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.4.0", + "version": "1.4.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 3693175b8..12325ecc7 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.4.0", + "version": "1.4.3", "type": "module", "license": "MIT", "exports": { diff --git a/packages/ui/src/components/apply-patch-file.test.ts b/packages/ui/src/components/apply-patch-file.test.ts new file mode 100644 index 000000000..6c5858156 --- /dev/null +++ b/packages/ui/src/components/apply-patch-file.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import { patchFiles } from "./apply-patch-file" +import { text } from "./session-diff" + +describe("apply patch file", () => { + test("parses patch metadata from the server", () => { + const file = patchFiles([ + { + filePath: "/tmp/a.ts", + relativePath: "a.ts", + type: "update", + patch: + "Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,2 +1,2 @@\n one\n-two\n+three\n", + additions: 1, + deletions: 1, + }, + ])[0] + + expect(file).toBeDefined() + expect(file?.view.fileDiff.name).toBe("a.ts") + expect(text(file!.view, "deletions")).toBe("one\ntwo\n") + expect(text(file!.view, "additions")).toBe("one\nthree\n") + }) + + test("keeps legacy before and after payloads working", () => { + const file = patchFiles([ + { + filePath: "/tmp/a.ts", + relativePath: "a.ts", + type: "update", + before: "one\n", + after: "two\n", + additions: 1, + deletions: 1, + }, + ])[0] + + expect(file).toBeDefined() + expect(file?.view.patch).toContain("@@ -1,1 +1,1 @@") + expect(text(file!.view, "deletions")).toBe("one\n") + expect(text(file!.view, "additions")).toBe("two\n") + }) +}) diff --git a/packages/ui/src/components/apply-patch-file.ts b/packages/ui/src/components/apply-patch-file.ts new file mode 100644 index 000000000..8e0c54082 --- /dev/null +++ b/packages/ui/src/components/apply-patch-file.ts @@ -0,0 +1,78 @@ +import { normalize, type ViewDiff } from "./session-diff" + +type Kind = "add" | "update" | "delete" | "move" + +type Raw = { + filePath?: string + relativePath?: string + type?: Kind + patch?: string + diff?: string + before?: string + after?: string + additions?: number + deletions?: number + movePath?: string +} + +export type ApplyPatchFile = { + filePath: string + relativePath: string + type: Kind + additions: number + deletions: number + movePath?: string + view: ViewDiff +} + +function kind(value: unknown) { + if (value === "add" || value === "update" || value === "delete" || value === "move") return value +} + +function status(type: Kind): "added" | "deleted" | "modified" { + if (type === "add") return "added" + if (type === "delete") return "deleted" + return "modified" +} + +export function patchFile(raw: unknown): ApplyPatchFile | undefined { + if (!raw || typeof raw !== "object") return + + const value = raw as Raw + const type = kind(value.type) + const filePath = typeof value.filePath === "string" ? value.filePath : undefined + const relativePath = typeof value.relativePath === "string" ? value.relativePath : filePath + const patch = typeof value.patch === "string" ? value.patch : typeof value.diff === "string" ? value.diff : undefined + const before = typeof value.before === "string" ? value.before : undefined + const after = typeof value.after === "string" ? value.after : undefined + + if (!type || !filePath || !relativePath) return + if (!patch && before === undefined && after === undefined) return + + const additions = typeof value.additions === "number" ? value.additions : 0 + const deletions = typeof value.deletions === "number" ? value.deletions : 0 + const movePath = typeof value.movePath === "string" ? value.movePath : undefined + + return { + filePath, + relativePath, + type, + additions, + deletions, + movePath, + view: normalize({ + file: relativePath, + patch, + before, + after, + additions, + deletions, + status: status(type), + }), + } +} + +export function patchFiles(raw: unknown) { + if (!Array.isArray(raw)) return [] + return raw.map(patchFile).filter((file): file is ApplyPatchFile => !!file) +} diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 3627eca40..02bd80ac9 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -54,6 +54,7 @@ import { Spinner } from "./spinner" import { TextShimmer } from "./text-shimmer" import { AnimatedCountList } from "./tool-count-summary" import { ToolStatusTitle } from "./tool-status-title" +import { patchFiles } from "./apply-patch-file" import { animate } from "motion" import { useLocation } from "@solidjs/router" import { attached, inline, kind } from "./message-file" @@ -2014,24 +2015,12 @@ ToolRegistry.register({ }, }) -interface ApplyPatchFile { - filePath: string - relativePath: string - type: "add" | "update" | "delete" | "move" - diff: string - before: string - after: string - additions: number - deletions: number - movePath?: string -} - ToolRegistry.register({ name: "apply_patch", render(props) { const i18n = useI18n() const fileComponent = useFileComponent() - const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) + const files = createMemo(() => patchFiles(props.metadata.files)) const pending = createMemo(() => props.status === "pending" || props.status === "running") const single = createMemo(() => { const list = files() @@ -2137,12 +2126,7 @@ ToolRegistry.register({

@@ -2212,12 +2196,7 @@ ToolRegistry.register({ } >
- +
diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 90da853ef..4a7205a5d 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -65,6 +65,26 @@ export type SessionReviewFocus = { file: string; id: string } type ReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult } +function diff(value: unknown): value is ReviewDiff { + if (!value || typeof value !== "object" || Array.isArray(value)) return false + if (!("file" in value) || typeof value.file !== "string") return false + if (!("additions" in value) || typeof value.additions !== "number") return false + if (!("deletions" in value) || typeof value.deletions !== "number") return false + if ("patch" in value && value.patch !== undefined && typeof value.patch !== "string") return false + if ("before" in value && value.before !== undefined && typeof value.before !== "string") return false + if ("after" in value && value.after !== undefined && typeof value.after !== "string") return false + if (!("status" in value) || value.status === undefined) return true + return value.status === "added" || value.status === "deleted" || value.status === "modified" +} + +function list(value: unknown): ReviewDiff[] { + if (Array.isArray(value) && value.every(diff)) return value + if (Array.isArray(value)) return value.filter(diff) + if (diff(value)) return [value] + if (!value || typeof value !== "object") return [] + return Object.values(value).filter(diff) +} + export interface SessionReviewProps { title?: JSX.Element empty?: JSX.Element @@ -157,7 +177,9 @@ export const SessionReview = (props: SessionReviewProps) => { const opened = () => store.opened const open = () => props.open ?? store.open - const items = createMemo(() => props.diffs.map((diff) => ({ ...normalize(diff), preloaded: diff.preloaded }))) + const items = createMemo(() => + list(props.diffs).map((diff) => ({ ...normalize(diff), preloaded: diff.preloaded })), + ) const files = createMemo(() => items().map((diff) => diff.file)) const grouped = createMemo(() => { const next = new Map() diff --git a/packages/util/package.json b/packages/util/package.json index 8b44dc7ae..bee3d082a 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.4.0", + "version": "1.4.3", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index 8f963eedf..df003f79f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.4.0", + "version": "1.4.3", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/packages/web/src/content/docs/ar/zen.mdx b/packages/web/src/content/docs/ar/zen.mdx index 27a2c8132..c7cfe57b7 100644 --- a/packages/web/src/content/docs/ar/zen.mdx +++ b/packages/web/src/content/docs/ar/zen.mdx @@ -213,7 +213,7 @@ https://opencode.ai/zen/v1/models - MiMo V2 Pro Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - MiMo V2 Omni Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - Qwen3.6 Plus Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. -- Nemotron 3 Super Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. +- Nemotron 3 Super Free (نقاط نهاية NVIDIA المجانية): يُقدَّم بموجب [شروط خدمة النسخة التجريبية من واجهة NVIDIA API](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). للاستخدام التجريبي فقط، وليس للإنتاج أو البيانات الحساسة. تقوم NVIDIA بتسجيل المطالبات والمخرجات لتحسين نماذجها وخدماتها. لا ترسل بيانات شخصية أو سرية. - OpenAI APIs: يتم الاحتفاظ بالطلبات لمدة 30 يوما وفقا لـ [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: يتم الاحتفاظ بالطلبات لمدة 30 يوما وفقا لـ [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/bs/zen.mdx b/packages/web/src/content/docs/bs/zen.mdx index d8b258f47..9edfdddd1 100644 --- a/packages/web/src/content/docs/bs/zen.mdx +++ b/packages/web/src/content/docs/bs/zen.mdx @@ -225,7 +225,7 @@ i ne koriste vaše podatke za treniranje modela, uz sljedeće izuzetke: - MiMo V2 Pro Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. - MiMo V2 Omni Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. - Qwen3.6 Plus Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. -- Nemotron 3 Super Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. +- Nemotron 3 Super Free (besplatni NVIDIA endpointi): Dostupan je prema [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Samo za probnu upotrebu, nije za produkciju niti osjetljive podatke. NVIDIA bilježi promptove i izlaze radi poboljšanja svojih modela i usluga. Nemojte slati lične ili povjerljive podatke. - OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/da/zen.mdx b/packages/web/src/content/docs/da/zen.mdx index 3660e528d..234293066 100644 --- a/packages/web/src/content/docs/da/zen.mdx +++ b/packages/web/src/content/docs/da/zen.mdx @@ -223,7 +223,7 @@ Alle vores modeller hostes i US. Vores udbydere følger en nul-opbevaringspoliti - MiMo V2 Pro Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. - MiMo V2 Omni Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. - Qwen3.6 Plus Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. -- Nemotron 3 Super Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. +- Nemotron 3 Super Free (gratis NVIDIA-endpoints): Leveres under [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Kun til prøvebrug, ikke til produktion eller følsomme data. Prompts og outputs logges af NVIDIA for at forbedre deres modeller og tjenester. Indsend ikke personlige eller fortrolige data. - OpenAI APIs: Anmodninger opbevares i 30 dage i overensstemmelse med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Anmodninger opbevares i 30 dage i overensstemmelse med [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/de/zen.mdx b/packages/web/src/content/docs/de/zen.mdx index 4e94788ee..e7e2d4e9b 100644 --- a/packages/web/src/content/docs/de/zen.mdx +++ b/packages/web/src/content/docs/de/zen.mdx @@ -209,7 +209,7 @@ Alle unsere Modelle werden in den USA gehostet. Unsere Provider folgen einer Zer - MiMo V2 Pro Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. - MiMo V2 Omni Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. - Qwen3.6 Plus Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. -- Nemotron 3 Super Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. +- Nemotron 3 Super Free (kostenlose NVIDIA-Endpunkte): Bereitgestellt gemäß den [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Nur für Testzwecke, nicht für Produktion oder sensible Daten. Eingaben und Ausgaben werden von NVIDIA protokolliert, um seine Modelle und Dienste zu verbessern. Übermitteln Sie keine personenbezogenen oder vertraulichen Daten. - OpenAI APIs: Anfragen werden in Übereinstimmung mit [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 30 Tage lang gespeichert. - Anthropic APIs: Anfragen werden in Übereinstimmung mit [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 30 Tage lang gespeichert. diff --git a/packages/web/src/content/docs/es/zen.mdx b/packages/web/src/content/docs/es/zen.mdx index b6acd296a..a68871fb4 100644 --- a/packages/web/src/content/docs/es/zen.mdx +++ b/packages/web/src/content/docs/es/zen.mdx @@ -223,7 +223,7 @@ Todos nuestros modelos están alojados en US. Nuestros proveedores siguen una po - MiMo V2 Pro Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. - MiMo V2 Omni Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. - Qwen3.6 Plus Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. -- Nemotron 3 Super Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. +- Nemotron 3 Super Free (endpoints gratuitos de NVIDIA): Se ofrece bajo los [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Solo para uso de prueba, no para producción ni datos sensibles. NVIDIA registra los prompts y las salidas para mejorar sus modelos y servicios. No envíes datos personales ni confidenciales. - OpenAI APIs: Las solicitudes se conservan durante 30 días de acuerdo con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Las solicitudes se conservan durante 30 días de acuerdo con [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/fr/zen.mdx b/packages/web/src/content/docs/fr/zen.mdx index f65fcac08..f1d76beae 100644 --- a/packages/web/src/content/docs/fr/zen.mdx +++ b/packages/web/src/content/docs/fr/zen.mdx @@ -209,7 +209,7 @@ Tous nos modèles sont hébergés aux US. Nos fournisseurs suivent une politique - MiMo V2 Pro Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. - MiMo V2 Omni Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. - Qwen3.6 Plus Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. -- Nemotron 3 Super Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. +- Nemotron 3 Super Free (endpoints NVIDIA gratuits) : Fourni dans le cadre des [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Réservé à un usage d'essai, pas à la production ni aux données sensibles. Les prompts et les sorties sont journalisés par NVIDIA pour améliorer ses modèles et services. N'envoyez pas de données personnelles ou confidentielles. - OpenAI APIs : Les requêtes sont conservées pendant 30 jours conformément à [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs : Les requêtes sont conservées pendant 30 jours conformément à [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/it/zen.mdx b/packages/web/src/content/docs/it/zen.mdx index c099c903c..b8bb388ef 100644 --- a/packages/web/src/content/docs/it/zen.mdx +++ b/packages/web/src/content/docs/it/zen.mdx @@ -223,7 +223,7 @@ Tutti i nostri modelli sono ospitati negli US. I nostri provider seguono una pol - MiMo V2 Pro Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. - MiMo V2 Omni Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. - Qwen3.6 Plus Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. -- Nemotron 3 Super Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. +- Nemotron 3 Super Free (endpoint NVIDIA gratuiti): fornito secondo i [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Solo per uso di prova, non per produzione o dati sensibili. NVIDIA registra prompt e output per migliorare i propri modelli e servizi. Non inviare dati personali o riservati. - OpenAI APIs: le richieste vengono conservate per 30 giorni in conformità con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: le richieste vengono conservate per 30 giorni in conformità con [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/ja/zen.mdx b/packages/web/src/content/docs/ja/zen.mdx index 93ec6fbec..f0536c389 100644 --- a/packages/web/src/content/docs/ja/zen.mdx +++ b/packages/web/src/content/docs/ja/zen.mdx @@ -209,7 +209,7 @@ https://opencode.ai/zen/v1/models - MiMo V2 Pro Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 - MiMo V2 Omni Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 - Qwen3.6 Plus Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 -- Nemotron 3 Super Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 +- Nemotron 3 Super Free(NVIDIA の無料エンドポイント): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) に基づいて提供されます。試用専用であり、本番環境や機密性の高いデータには使用しないでください。プロンプトと出力は、NVIDIA が自社のモデルとサービスを改善するために記録します。個人情報や機密データは送信しないでください。 - OpenAI APIs: リクエストは [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) に従って 30 日間保持されます。 - Anthropic APIs: リクエストは [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) に従って 30 日間保持されます。 diff --git a/packages/web/src/content/docs/ko/zen.mdx b/packages/web/src/content/docs/ko/zen.mdx index 08f57eaf9..a16eed426 100644 --- a/packages/web/src/content/docs/ko/zen.mdx +++ b/packages/web/src/content/docs/ko/zen.mdx @@ -209,7 +209,7 @@ https://opencode.ai/zen/v1/models - MiMo V2 Pro Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - MiMo V2 Omni Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - Qwen3.6 Plus Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. -- Nemotron 3 Super Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. +- Nemotron 3 Super Free(NVIDIA 무료 엔드포인트): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)에 따라 제공됩니다. 평가판 전용이며 프로덕션 환경이나 민감한 데이터에는 사용할 수 없습니다. NVIDIA는 자사 모델과 서비스를 개선하기 위해 프롬프트와 출력을 기록합니다. 개인 정보나 기밀 데이터는 제출하지 마세요. - OpenAI APIs: 요청은 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data)에 따라 30일 동안 보관됩니다. - Anthropic APIs: 요청은 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage)에 따라 30일 동안 보관됩니다. diff --git a/packages/web/src/content/docs/nb/zen.mdx b/packages/web/src/content/docs/nb/zen.mdx index 02ae767b6..e89c9ef31 100644 --- a/packages/web/src/content/docs/nb/zen.mdx +++ b/packages/web/src/content/docs/nb/zen.mdx @@ -223,7 +223,7 @@ Alle modellene våre hostes i US. Leverandørene våre følger en policy for zer - MiMo V2 Pro Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. - MiMo V2 Omni Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. - Qwen3.6 Plus Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. -- Nemotron 3 Super Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. +- Nemotron 3 Super Free (gratis NVIDIA-endepunkter): Leveres under [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Kun for prøvebruk, ikke for produksjon eller sensitive data. Prompter og svar logges av NVIDIA for å forbedre modellene og tjenestene deres. Ikke send inn personopplysninger eller konfidensielle data. - OpenAI APIs: Forespørsler lagres i 30 dager i samsvar med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Forespørsler lagres i 30 dager i samsvar med [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/pl/zen.mdx b/packages/web/src/content/docs/pl/zen.mdx index 98c8b85ef..2687ec608 100644 --- a/packages/web/src/content/docs/pl/zen.mdx +++ b/packages/web/src/content/docs/pl/zen.mdx @@ -224,7 +224,7 @@ Wszystkie nasze modele są hostowane w US. Nasi dostawcy stosują politykę zero - MiMo V2 Pro Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. - MiMo V2 Omni Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. - Qwen3.6 Plus Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. -- Nemotron 3 Super Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. +- Nemotron 3 Super Free (darmowe endpointy NVIDIA): Udostępniany zgodnie z [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Tylko do użytku próbnego, nie do produkcji ani danych wrażliwych. NVIDIA rejestruje prompty i odpowiedzi, aby ulepszać swoje modele i usługi. Nie przesyłaj danych osobowych ani poufnych. - OpenAI APIs: Żądania są przechowywane przez 30 dni zgodnie z [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Żądania są przechowywane przez 30 dni zgodnie z [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/pt-br/zen.mdx b/packages/web/src/content/docs/pt-br/zen.mdx index fd6848df7..6cbc810da 100644 --- a/packages/web/src/content/docs/pt-br/zen.mdx +++ b/packages/web/src/content/docs/pt-br/zen.mdx @@ -209,7 +209,7 @@ Todos os nossos modelos são hospedados nos US. Nossos provedores seguem uma pol - MiMo V2 Pro Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. - MiMo V2 Omni Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. - Qwen3.6 Plus Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. -- Nemotron 3 Super Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. +- Nemotron 3 Super Free (endpoints gratuitos da NVIDIA): Fornecido sob os [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Apenas para uso de avaliação, não para produção nem dados sensíveis. A NVIDIA registra prompts e saídas para melhorar seus modelos e serviços. Não envie dados pessoais ou confidenciais. - OpenAI APIs: As solicitações são retidas por 30 dias de acordo com [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: As solicitações são retidas por 30 dias de acordo com [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/ru/zen.mdx b/packages/web/src/content/docs/ru/zen.mdx index 7a087d85a..555cda1f3 100644 --- a/packages/web/src/content/docs/ru/zen.mdx +++ b/packages/web/src/content/docs/ru/zen.mdx @@ -223,7 +223,7 @@ https://opencode.ai/zen/v1/models - MiMo V2 Pro Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - MiMo V2 Omni Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - Qwen3.6 Plus Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. -- Nemotron 3 Super Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. +- Nemotron 3 Super Free (бесплатные эндпоинты NVIDIA): предоставляется в соответствии с [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Только для пробного использования, не для продакшена и не для чувствительных данных. NVIDIA логирует запросы и ответы, чтобы улучшать свои модели и сервисы. Не отправляйте персональные или конфиденциальные данные. - OpenAI APIs: запросы хранятся 30 дней в соответствии с [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: запросы хранятся 30 дней в соответствии с [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/th/zen.mdx b/packages/web/src/content/docs/th/zen.mdx index cf272576c..527bcf7db 100644 --- a/packages/web/src/content/docs/th/zen.mdx +++ b/packages/web/src/content/docs/th/zen.mdx @@ -211,7 +211,7 @@ https://opencode.ai/zen/v1/models - MiMo V2 Pro Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - MiMo V2 Omni Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - Qwen3.6 Plus Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล -- Nemotron 3 Super Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล +- Nemotron 3 Super Free (endpoint ฟรีของ NVIDIA): ให้บริการภายใต้ [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) ใช้สำหรับการทดลองเท่านั้น ไม่เหมาะสำหรับ production หรือข้อมูลที่อ่อนไหว NVIDIA จะบันทึก prompt และ output เพื่อนำไปปรับปรุงโมเดลและบริการของตน โปรดอย่าส่งข้อมูลส่วนบุคคลหรือข้อมูลลับ. - OpenAI APIs: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/tr/zen.mdx b/packages/web/src/content/docs/tr/zen.mdx index 89276dd4c..17166f84d 100644 --- a/packages/web/src/content/docs/tr/zen.mdx +++ b/packages/web/src/content/docs/tr/zen.mdx @@ -209,7 +209,7 @@ Tüm modellerimiz US'de barındırılıyor. Sağlayıcılarımız zero-retention - MiMo V2 Pro Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. - MiMo V2 Omni Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. - Qwen3.6 Plus Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. -- Nemotron 3 Super Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. +- Nemotron 3 Super Free (ücretsiz NVIDIA uç noktaları): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) kapsamında sunulur. Yalnızca deneme amaçlıdır; üretim veya hassas veriler için uygun değildir. NVIDIA, modellerini ve hizmetlerini geliştirmek için promptları ve çıktıları kaydeder. Kişisel veya gizli veri göndermeyin. - OpenAI APIs: İstekler [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) uyarınca 30 gün boyunca saklanır. - Anthropic APIs: İstekler [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) uyarınca 30 gün boyunca saklanır. diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx index f16ec6f71..cd574a30b 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -215,7 +215,7 @@ All our models are hosted in the US. Our providers follow a zero-retention polic - Big Pickle: During its free period, collected data may be used to improve the model. - MiniMax M2.5 Free: During its free period, collected data may be used to improve the model. - Qwen3.6 Plus Free: During its free period, collected data may be used to improve the model. -- Nemotron 3 Super Free: During its free period, collected data may be used to improve the model. +- Nemotron 3 Super Free (NVIDIA free endpoints): Provided under the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Trial use only — not for production or sensitive data. Prompts and outputs are logged by NVIDIA to improve its models and services. Do not submit personal or confidential data. - OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/zh-cn/zen.mdx b/packages/web/src/content/docs/zh-cn/zen.mdx index 5df360954..f17a0f842 100644 --- a/packages/web/src/content/docs/zh-cn/zen.mdx +++ b/packages/web/src/content/docs/zh-cn/zen.mdx @@ -209,7 +209,7 @@ https://opencode.ai/zen/v1/models - MiMo V2 Pro Free:在免费期间,收集的数据可能会被用于改进模型。 - MiMo V2 Omni Free:在免费期间,收集的数据可能会被用于改进模型。 - Qwen3.6 Plus Free:在免费期间,收集的数据可能会被用于改进模型。 -- Nemotron 3 Super Free:在免费期间,收集的数据可能会被用于改进模型。 +- Nemotron 3 Super Free(NVIDIA 免费端点):根据 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) 提供。仅供试用,不适用于生产环境或敏感数据。NVIDIA 会记录提示词和输出内容,以改进其模型和服务。请勿提交个人或机密数据。 - OpenAI APIs:请求会根据 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。 - Anthropic APIs:请求会根据 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 保留 30 天。 diff --git a/packages/web/src/content/docs/zh-tw/zen.mdx b/packages/web/src/content/docs/zh-tw/zen.mdx index 1b2beb2c1..3a6448b59 100644 --- a/packages/web/src/content/docs/zh-tw/zen.mdx +++ b/packages/web/src/content/docs/zh-tw/zen.mdx @@ -216,7 +216,7 @@ https://opencode.ai/zen/v1/models - MiMo V2 Pro Free: 在免費期間,收集到的資料可能會用於改進模型。 - MiMo V2 Omni Free: 在免費期間,收集到的資料可能會用於改進模型。 - Qwen3.6 Plus Free: 在免費期間,收集到的資料可能會用於改進模型。 -- Nemotron 3 Super Free: 在免費期間,收集到的資料可能會用於改進模型。 +- Nemotron 3 Super Free(NVIDIA 免費端點):依據 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) 提供。僅供試用,不適用於正式環境或敏感資料。NVIDIA 會記錄提示詞與輸出內容,以改進其模型與服務。請勿提交個人或機密資料。 - OpenAI APIs: 請求會依據 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。 - Anthropic APIs: 請求會依據 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 保留 30 天。 diff --git a/patches/install-korean-ime-fix.sh b/patches/install-korean-ime-fix.sh new file mode 100755 index 000000000..8d6c60b64 --- /dev/null +++ b/patches/install-korean-ime-fix.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -euo pipefail + +# opencode Korean IME Fix Installer +# https://github.com/anomalyco/opencode/issues/14371 +# +# Patches opencode to prevent Korean (and other CJK) IME last character +# truncation when pressing Enter in Kitty and other terminals. +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/claudianus/opencode/fix-zhipuai-coding-plan-thinking/patches/install-korean-ime-fix.sh | bash +# # or from a cloned repo: +# ./patches/install-korean-ime-fix.sh + +RED='\033[0;31m' +GREEN='\033[0;32m' +ORANGE='\033[38;5;214m' +MUTED='\033[0;2m' +NC='\033[0m' + +OPENCODE_DIR="${OPENCODE_DIR:-$HOME/.opencode}" +OPENCODE_SRC="${OPENCODE_SRC:-$HOME/.opencode-src}" +FORK_REPO="${FORK_REPO:-https://github.com/claudianus/opencode.git}" +FORK_BRANCH="${FORK_BRANCH:-fix-zhipuai-coding-plan-thinking}" + +info() { echo -e "${MUTED}$*${NC}"; } +warn() { echo -e "${ORANGE}$*${NC}"; } +err() { echo -e "${RED}$*${NC}" >&2; } +ok() { echo -e "${GREEN}$*${NC}"; } + +need() { + if ! command -v "$1" >/dev/null 2>&1; then + err "Error: $1 is required but not installed." + exit 1 + fi +} + +need git +need bun + +# ── 1. Clone or update fork ──────────────────────────────────────────── +if [ -d "$OPENCODE_SRC/.git" ]; then + info "Updating existing source at $OPENCODE_SRC ..." + git -C "$OPENCODE_SRC" fetch origin "$FORK_BRANCH" + git -C "$OPENCODE_SRC" checkout "$FORK_BRANCH" + git -C "$OPENCODE_SRC" reset --hard "origin/$FORK_BRANCH" +else + info "Cloning fork (shallow) to $OPENCODE_SRC ..." + git clone --depth 1 --branch "$FORK_BRANCH" "$FORK_REPO" "$OPENCODE_SRC" +fi + +# ── 2. Verify the IME fix is present in source ──────────────────────── +PROMPT_FILE="$OPENCODE_SRC/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx" +if [ ! -f "$PROMPT_FILE" ]; then + err "Prompt file not found: $PROMPT_FILE" + exit 1 +fi + +if grep -q "setTimeout(() => setTimeout" "$PROMPT_FILE"; then + ok "IME fix already present in source." +else + warn "IME fix not found. Applying patch ..." + # Apply the fix: replace onSubmit={submit} with double-deferred version + sed -i 's|onSubmit={submit}|onSubmit={() => {\n // IME: double-defer so the last composed character (e.g. Korean\n // hangul) is flushed to plainText before we read it for submission.\n setTimeout(() => setTimeout(() => submit(), 0), 0)\n }}|' "$PROMPT_FILE" + if grep -q "setTimeout(() => setTimeout" "$PROMPT_FILE"; then + ok "Patch applied." + else + err "Failed to apply patch. The source may have changed." + exit 1 + fi +fi + +# ── 3. Install dependencies ──────────────────────────────────────────── +info "Installing dependencies (this may take a minute) ..." +cd "$OPENCODE_SRC" +bun install --frozen-lockfile 2>/dev/null || bun install + +# ── 4. Build (current platform only) ────────────────────────────────── +info "Building opencode for current platform ..." +cd "$OPENCODE_SRC/packages/opencode" +bun run build --single + +# ── 5. Install binary ────────────────────────────────────────────────── +mkdir -p "$OPENCODE_DIR/bin" + +PLATFORM=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) +[ "$ARCH" = "aarch64" ] && ARCH="arm64" +[ "$ARCH" = "x86_64" ] && ARCH="x64" +[ "$PLATFORM" = "darwin" ] && true +[ "$PLATFORM" = "linux" ] && true + +BUILT_BINARY="$OPENCODE_SRC/packages/opencode/dist/opencode-${PLATFORM}-${ARCH}/bin/opencode" + +if [ ! -f "$BUILT_BINARY" ]; then + BUILT_BINARY=$(find "$OPENCODE_SRC/packages/opencode/dist" -name "opencode" -type f -executable 2>/dev/null | head -1) +fi + +if [ -f "$BUILT_BINARY" ]; then + if [ -f "$OPENCODE_DIR/bin/opencode" ]; then + cp "$OPENCODE_DIR/bin/opencode" "$OPENCODE_DIR/bin/opencode.bak.$(date +%Y%m%d%H%M%S)" + fi + cp "$BUILT_BINARY" "$OPENCODE_DIR/bin/opencode" + chmod +x "$OPENCODE_DIR/bin/opencode" + ok "Installed to $OPENCODE_DIR/bin/opencode" +else + err "Build failed - binary not found in dist/" + info "Try running manually:" + echo " cd $OPENCODE_SRC/packages/opencode && bun run build --single" + exit 1 +fi + +echo "" +ok "Done! Korean IME fix is now active." +echo "" +info "To uninstall and revert to the official release:" +echo " curl -fsSL https://opencode.ai/install | bash" +echo "" +info "To update (re-pull and rebuild):" +echo " $0" diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 2004bd733..290834180 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.4.0", + "version": "1.4.3", "publisher": "sst-dev", "repository": { "type": "git", diff --git a/specs/v2/session.md b/specs/v2/session.md new file mode 100644 index 000000000..cae90ba7c --- /dev/null +++ b/specs/v2/session.md @@ -0,0 +1,17 @@ +# Session API + +## Remove Dedicated `session.init` Route + +The dedicated `POST /session/:sessionID/init` endpoint exists only as a compatibility wrapper around the normal `/init` command flow. + +Current behavior: + +- the route calls `SessionPrompt.command(...)` +- it sends `Command.Default.INIT` +- it does not provide distinct session-core behavior beyond running the existing init command in an existing session + +V2 plan: + +- remove the dedicated `session.init` endpoint +- rely on the normal `/init` command flow instead +- avoid reintroducing `Session.initialize`-style special cases in the session service layer